Compare commits
3
Commits
bc145fe853
..
docs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e30dffb31 | ||
|
|
ddacc97b18 | ||
|
|
41909b9169 |
@@ -1,7 +0,0 @@
|
|||||||
# 统一换行符,避免 Windows/Linux 来回转换把每个文件都标成 modified
|
|
||||||
* text=auto
|
|
||||||
*.py text eol=lf
|
|
||||||
*.md text eol=lf
|
|
||||||
*.cs text eol=crlf
|
|
||||||
*.csproj text eol=crlf
|
|
||||||
*.json text eol=lf
|
|
||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
# ---- 运行产物 ----
|
|
||||||
# 每账号一份用户数据快照(<userId>.json)
|
|
||||||
data/
|
|
||||||
logs/
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
|
|
||||||
# ---- 本地查证用(含 SEGA 二进制 / 反编译代码 / 真机日志,不入库)----
|
|
||||||
source/
|
|
||||||
|
|
||||||
# ---- 私有配置:只提交 config.py.example,不提交 config.py ----
|
|
||||||
config.py
|
|
||||||
|
|
||||||
# ---- 文档单独放 docs 分支,主分支不含 ----
|
|
||||||
docs/
|
|
||||||
|
|
||||||
# ---- .NET 构建产物 ----
|
|
||||||
gamehook/bin/
|
|
||||||
gamehook/obj/
|
|
||||||
|
|
||||||
# ---- netdelivery 下载目录(.app / .opt 更新包很大,不入库)----
|
|
||||||
download/
|
|
||||||
|
|
||||||
# ---- get_auth_list:clientId 遍历脚本与其产物(默认不入库、不推送)----
|
|
||||||
# 产物默认落在 data/ 下(data/ 本就已经整目录忽略);
|
|
||||||
# authentication.* 再兜一层,防止用 --csv/--state 指到别处
|
|
||||||
get_auth_list.py
|
|
||||||
authentication.*
|
|
||||||
|
|
||||||
# ---- 编辑器 / 系统 ----
|
|
||||||
.vs/
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.user
|
|
||||||
Thumbs.db
|
|
||||||
Desktop.ini
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
<h1 align="center">WaHappy</h1>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<b>舞萌DX 的游戏助手</b><br>
|
|
||||||
宗旨:让舞萌DX的玩家能够更好地游玩游戏,获得快乐。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">AI真的很好用你知道吗</p>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 这是什么
|
|
||||||
|
|
||||||
WaHappy 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游戏服务器交互的工具集。
|
|
||||||
|
|
||||||
它不修改游戏、不注入进程,只是把游戏客户端做的事用脚本重做一遍:
|
|
||||||
扫码登录 → 取数据 → 上传成绩 → 登出。
|
|
||||||
|
|
||||||
> 协议实现全部来自反编译源码与真机网络日志的双向验证,
|
|
||||||
> 每个关键常量在代码注释里都标了**源码行号**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 当前状态:v0
|
|
||||||
|
|
||||||
| 脚本 | 状态 |
|
|
||||||
|---|---|
|
|
||||||
| **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 |
|
|
||||||
| **`buy_ticket.py`** | ✅ **可用** —— 购买功能票 |
|
|
||||||
| **`use_ticket.py`** | ✅ **可用** —— 消耗当前持有的功能票 |
|
|
||||||
| **`reincarnate.py`** | 🔧 已实现(**未实跑**)—— 账号数据迁移(转生) |
|
|
||||||
| **`netdelivery.py`** | ✅ **可用** —— 查 / 下 `.app` / `.opt` 更新包(只读) |
|
|
||||||
| `api/` 下 40+ 个接口模块 | 🔧 已实现并通过零网络自检,但**未全部在真机上验证过** |
|
|
||||||
|
|
||||||
**文档**在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs),见文末[文档索引](#文档索引)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一个教训:别把一件事分段交给 AI
|
|
||||||
|
|
||||||
v0 的开发过程中,我反复把同一个目标拆成小段丢给 AI,结果:
|
|
||||||
|
|
||||||
| 项目 | Token 消耗 |
|
|
||||||
|---|---|
|
|
||||||
| **v0 有效开发** | **473M+** |
|
|
||||||
| 含中途废弃的分支项目 | **1137.5M** |
|
|
||||||
|
|
||||||
**这 1137.5M 里有一大半是浪费掉的。**
|
|
||||||
|
|
||||||
真正的教训是:
|
|
||||||
|
|
||||||
> **不要把一个东西分段交给 AI,而是直接设定一个目标,然后让它自己跑完。**
|
|
||||||
|
|
||||||
分段交接会让 AI 反复丢失上下文、重复劳动、在已经解决的问题上打转 ——
|
|
||||||
而一个清晰的目标 + 足够长的执行窗口,反而更快也更省。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 项目框架
|
|
||||||
|
|
||||||
```
|
|
||||||
WaHappy/
|
|
||||||
├── config.py 运行时可配置项(机台标识 / 歌曲数据 / 日志级别)★ 需自建
|
|
||||||
├── config.py.example 配置模板
|
|
||||||
├── upsert.py ★ 主脚本:扫码登录 → 上传成绩 → 登出
|
|
||||||
├── buy_ticket.py 主脚本:购买功能票
|
|
||||||
├── use_ticket.py 主脚本:消耗当前持有的功能票
|
|
||||||
├── reincarnate.py 主脚本:账号数据迁移(转生)
|
|
||||||
├── netdelivery.py 主脚本:查 / 下 .app / .opt 更新包
|
|
||||||
├── selftest.py 零网络自检(249 项,判据 = 源码 + 真机日志)
|
|
||||||
│
|
|
||||||
├── 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/<userId>.json
|
|
||||||
│ └── ... 40+ 个接口脚本
|
|
||||||
│
|
|
||||||
├── data/ 每账号一份用户数据快照(<userId>.json)★ 不入库
|
|
||||||
├── download/ netdelivery.py 下载的 .app / .opt ★ 不入库
|
|
||||||
├── logs/ 日志输出(latest.log + 日期.log)★ 不入库
|
|
||||||
└── source/ 反编译源码 ★ 不入库,需自备
|
|
||||||
```
|
|
||||||
|
|
||||||
**设计约定**:`api/` 里每个脚本 = 一个接口,对外暴露一个**能传参的函数**。
|
|
||||||
主脚本只负责编排,日志和协议细节都在 `api/` 内部。
|
|
||||||
|
|
||||||
**命名约定**:服务端 API 名里带 `Get` 的,**脚本名与函数名一律带 `get` / `Get`**
|
|
||||||
(不要模棱两可)。所以取数脚本全是 `get_*.py`,函数是 `GetXxx`。
|
|
||||||
|
|
||||||
### 分支说明
|
|
||||||
|
|
||||||
| 分支 | 内容 |
|
|
||||||
|---|---|
|
|
||||||
| **`mai`** | 主分支 —— 代码 |
|
|
||||||
| **`docs`** | 孤儿分支 —— 只有 `docs/`,开发文档与踩坑记录 |
|
|
||||||
|
|
||||||
两个分支**没有共同的提交历史**,各自独立。
|
|
||||||
`source/` 是反编译源码,**需要自备**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 1. 装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install requests pycryptodome
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. ★ 先填 `config.py`
|
|
||||||
|
|
||||||
仓库里只有模板 `config.py.example`,先复制一份:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp config.py.example config.py # Linux / macOS
|
|
||||||
copy config.py.example config.py # Windows
|
|
||||||
```
|
|
||||||
|
|
||||||
然后**必须**填好 **`config.py` 第二节「机台与店铺标识」**:
|
|
||||||
|
|
||||||
| 变量 | 填什么 |
|
|
||||||
|---|---|
|
|
||||||
| `CLIENT_ID` | Keychip 短值(15 位 Keychip 去掉连字符取前 11 位),形如 `A63E01ABCDE` |
|
|
||||||
| `PLACE_ID` | 你所在店铺 ID |
|
|
||||||
| `REGION_ID` | 你所在区域 ID |
|
|
||||||
| `PLACE_NAME` | 店铺名 |
|
|
||||||
| `REGION_NAME` | 区域名 |
|
|
||||||
| `KEYCHIP_ID` | **由上面两个值自动推导**,不要手改 |
|
|
||||||
|
|
||||||
> ⚠️ 填错的话,发出去的请求带的就是别人的机台身份,服务端会直接拒绝。
|
|
||||||
>
|
|
||||||
> `config.py` 已在 `.gitignore` 里 —— 它是你的私有配置,**不要提交**。
|
|
||||||
|
|
||||||
### 3. 跑自检
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python selftest.py # 零网络自检,应该 249 项全绿(不依赖你的 config)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 主脚本一览
|
|
||||||
|
|
||||||
| 脚本 | 干什么 | 副作用 | 需要二维码 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `upsert.py` | 上传一局成绩 | ★ 有 | ✅ |
|
|
||||||
| `buy_ticket.py` | 购买功能票 | ★ 有 | ✅ |
|
|
||||||
| `use_ticket.py` | 消耗当前持有的功能票 | ★ 有 | ✅ |
|
|
||||||
| `reincarnate.py` | 账号数据迁移(转生) | ★ 有 | ✅(目标号;源号可选) |
|
|
||||||
| `netdelivery.py` | 查 / 下 `.app` / `.opt` 更新包 | 只读 | ❌ |
|
|
||||||
| `selftest.py` | 零网络自检 | 无 | ❌ |
|
|
||||||
|
|
||||||
> **所有有副作用的脚本默认都不发包**:不传 `--send` 时只构造报文(dry-run)。
|
|
||||||
> 想看报文而不产生任何后果,先跑一次不带 `--send` 的。
|
|
||||||
|
|
||||||
> 二维码从机台的「扫码登录」界面获取,**有效期 10 分钟**(不是按次数计量的)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## upsert.py —— 上传一局成绩
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 预检:登录 + 抓快照 + 组包,不发包(零副作用,建议先跑这个)
|
|
||||||
python upsert.py "<二维码>"
|
|
||||||
|
|
||||||
# 真实上传一局成绩(★ 有副作用,不可撤销)
|
|
||||||
python upsert.py "<二维码>" --send
|
|
||||||
|
|
||||||
# 常用开关
|
|
||||||
python upsert.py "<二维码>" --send --debug # 日志里带完整收发报文
|
|
||||||
python upsert.py "<二维码>" --send --cooldown 0 # 跳过发包前 60 秒冷却
|
|
||||||
python upsert.py "<二维码>" --send --no-verify # 跳过上传后的落库回查
|
|
||||||
```
|
|
||||||
|
|
||||||
**换歌 / 换成绩只改 `config.py` 的 `PLAY_*`,不用动任何代码。**
|
|
||||||
|
|
||||||
### 额外塞节点:`--extra-data`
|
|
||||||
|
|
||||||
想手工覆盖某几个节点(比如本局拿到的新 KaleidxScope 门):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python upsert.py "<二维码>" --send --extra-data extra.json
|
|
||||||
```
|
|
||||||
|
|
||||||
键必须是 `upsertUserAll` 的 **36 个节点**之一,写错会报错并列出可用节点名。
|
|
||||||
**JSON 在扫码之前就校验**,写错直接退出,不浪费二维码。
|
|
||||||
|
|
||||||
> ⚠️ 在 PowerShell / CMD 里写内联 JSON 很容易被吞掉双引号,**建议一律用文件**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## buy_ticket.py —— 购买功能票
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python buy_ticket.py "<二维码>" --list # 只列当前票券和可买的票
|
|
||||||
python buy_ticket.py "<二维码>" --charge-id 2 # 预检:只看报文
|
|
||||||
python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买
|
|
||||||
```
|
|
||||||
|
|
||||||
| chargeId | 票 | 上报的 `price` |
|
|
||||||
|---|---|---|
|
|
||||||
| 2 | 2倍チケット | 1 |
|
|
||||||
| 3 | 3倍チケット | 2 |
|
|
||||||
| 5 | 5倍チケット | 4 |
|
|
||||||
|
|
||||||
> 默认 `--charge-id 5`。**不同店铺上架的票不一样**,买之前先 `--list`。
|
|
||||||
|
|
||||||
### 它做的安全检查
|
|
||||||
|
|
||||||
1. 买之前查 `GetGameChargeApi` —— 目标票不在可购买清单里就**连登录都不做**直接退出
|
|
||||||
2. 跑前查 `GetUserChargeApi` —— **账号已持有功能票时直接拒绝购买**(重复购买有封号风险)
|
|
||||||
3. 跑后再查一次确认状态
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## use_ticket.py —— 消耗当前持有的功能票
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python use_ticket.py "<二维码>" --list # 只列当前持有的票
|
|
||||||
python use_ticket.py "<二维码>" --ticket-id 2 --send # ★ 真实消耗
|
|
||||||
```
|
|
||||||
|
|
||||||
**原理**:`GetUserChargeApi` 与 `UpsertUserAllApi` 里的 `userChargeList` **格式完全相同**,
|
|
||||||
所以把该节点里目标票的 `stock` 减 1,再让 playlog 带上 `useTicketId` / `playCredit`,
|
|
||||||
服务端就认为这张票被这一局用掉了。
|
|
||||||
|
|
||||||
> **典型用途**:账号手里挂着票时 `buy_ticket.py` 会拒绝再买;
|
|
||||||
> 想再买一张就先用这个把它用掉。
|
|
||||||
|
|
||||||
详细原理与报文依据见 [功能票使用说明](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/功能票使用说明.md)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## reincarnate.py —— 账号转生
|
|
||||||
|
|
||||||
把一个账号(源 A)的数据克隆到另一个账号(目标 B)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 源数据来自二维码(会联网抓 A 的数据并落盘到 data/<A>.json)
|
|
||||||
python reincarnate.py --dst-qr "<B的二维码>" --src-qr "<A的二维码>"
|
|
||||||
|
|
||||||
# 源数据来自已保存的 JSON(离线,推荐)
|
|
||||||
python reincarnate.py --dst-qr "<B的二维码>" --src-json data/12345678.json --plan
|
|
||||||
python reincarnate.py --dst-qr "<B的二维码>" --src-json data/12345678.json --send
|
|
||||||
```
|
|
||||||
|
|
||||||
**它干了什么**:
|
|
||||||
|
|
||||||
1. 取源账号 A 的 19 个节点数据(联网抓或读 `data/<A>.json`)
|
|
||||||
2. 用目标账号 B 的二维码登录
|
|
||||||
3. 把 A 的 `userMusicDetailList` **分批**上传到 B —— 每包最多 4 首,
|
|
||||||
并自动避开**被服务端禁止的 2 首包**(`[4,4,2]` → `[4,3,3]`)
|
|
||||||
4. 每包之间:**等待 → 上传 → 登出(type=1)→ 续关登录**,循环到传完
|
|
||||||
5. 每包的 playlog 从曲目记录**反推合成**(`achievement` / `deluxscore` / `scoreRank` …)
|
|
||||||
|
|
||||||
| 参数 | 默认 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `--plan` | — | 只列分批计划,不发包(**建议先跑这个**) |
|
|
||||||
| `--limit N` | 0(全部) | 只迁前 N 首(试跑用) |
|
|
||||||
| `--per-packet N` | 4 | 每包曲目数,**只能是 1 / 3 / 4** |
|
|
||||||
| `--wait N` | 5.0 | 每次上传前的等待秒数 |
|
|
||||||
| `--clone-identity` | 关 | 连 `userName` / `accessCode` 一起克隆(默认保留 B 自己的) |
|
|
||||||
|
|
||||||
> ⚠️ **未实跑过。** 建议先 `--plan`,再 `--limit 8` 小批量试。
|
|
||||||
|
|
||||||
完整流程、分批规则、续关与登出类型的依据见 [账号转生说明](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/账号转生说明.md)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## netdelivery.py —— 查 / 下 `.app` / `.opt` 更新包
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python netdelivery.py # 用 config.py 里的机台查,然后**多选下载**
|
|
||||||
python netdelivery.py A63E01ABCDE # 指定 Keychip
|
|
||||||
python netdelivery.py --list # 只列清单 + 直链,不下载
|
|
||||||
python netdelivery.py --all # 全下(不交互)
|
|
||||||
python netdelivery.py --only 1,3 # 只下第 1、3 项(不交互)
|
|
||||||
python netdelivery.py --ver 1.55 # 指定上报版本(默认 config.MAI_ENCODING)
|
|
||||||
python netdelivery.py --out D:\SDGB2026\update
|
|
||||||
```
|
|
||||||
|
|
||||||
交互选择支持:逗号/空格分隔、区间(`1-3`)、`a`=全选、`n`=全不选、`q`=退出。
|
|
||||||
下载带**断点续传**与进度/速率显示,默认存到 `download/`(不入库)。
|
|
||||||
|
|
||||||
> 这是**只读**操作,不写任何账号数据。
|
|
||||||
> 需要同目录下的 `authv1.py` 提供 ALL.Net 协议层。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 主脚本怎么写
|
|
||||||
|
|
||||||
**核心原则:不要为每个 api 调用写日志。** 日志已经在 `api/` 里了。
|
|
||||||
|
|
||||||
```python
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import sys
|
|
||||||
from api import (setup_logging, teardown_logging,
|
|
||||||
ParseQRCode, GetUserPreview, UserLogin, UserLogout, LogoutType,
|
|
||||||
UpsertUserAll)
|
|
||||||
from config import REGION_ID, PLACE_ID
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
setup_logging("我的脚本") # ← 只需要这一句
|
|
||||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
|
||||||
try:
|
|
||||||
user_id, token = ParseQRCode(sys.argv[1])
|
|
||||||
session.update(user_id=user_id, token=token)
|
|
||||||
|
|
||||||
GetUserPreview(user_id, token) # 顶号保护
|
|
||||||
login = UserLogin(user_id, token,
|
|
||||||
region_id=REGION_ID, place_id=PLACE_ID)
|
|
||||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
|
||||||
|
|
||||||
# ★ 一个函数跑完整条结算链路
|
|
||||||
UpsertUserAll(user_id, login.loginId,
|
|
||||||
login_date_time=login.loginDateTime,
|
|
||||||
auth_date_time=login.dateTime,
|
|
||||||
token=token,
|
|
||||||
dry_run=False, confirm=True)
|
|
||||||
return 0
|
|
||||||
finally:
|
|
||||||
if session["logged_in"]: # ★ 一定要保证登出
|
|
||||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
|
||||||
logout_type=LogoutType.Logout,
|
|
||||||
region_id=REGION_ID, place_id=PLACE_ID)
|
|
||||||
teardown_logging()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
```
|
|
||||||
|
|
||||||
三条铁律:
|
|
||||||
|
|
||||||
1. **不要写日志** —— 每个 api 函数会自动打印 `=== 函数名 开始运行 ===` / `[运行结果]`
|
|
||||||
2. **一定要登出** —— 账号卡在 `isLogin=True` 时下次登录会 `returnCode=100`
|
|
||||||
3. **写接口要显式开闸门** —— 只写 `dry_run=False` 不够,必须同时给 `confirm=True`
|
|
||||||
|
|
||||||
完整指南见 [主脚本编写指南](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/主脚本编写指南.md)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 已完成 / 将来
|
|
||||||
|
|
||||||
**已完成(v0)**
|
|
||||||
|
|
||||||
- ✅ 扫码登录 / 登出 / 心跳,完整会话链路
|
|
||||||
- ✅ 上传一局成绩(36 节点 / 110 字段 / 差分 / 落库回查)
|
|
||||||
- ✅ 购买功能票
|
|
||||||
- ✅ 消耗持有的功能票
|
|
||||||
- ✅ 账号数据快照落盘(`data/<userId>.json`)
|
|
||||||
- ✅ 账号数据迁移(转生)
|
|
||||||
- ✅ 查 / 下 `.app` / `.opt` 更新包
|
|
||||||
- ✅ 零网络自检 249 项
|
|
||||||
- ✅ MelonLoader 抓包 Mod(`gamehook/`)
|
|
||||||
|
|
||||||
**将来可能会加**
|
|
||||||
|
|
||||||
- **网页控制** —— 用浏览器代替命令行操作
|
|
||||||
- 更多游戏功能(任务 / 地图 / 收藏夹 / 排行榜……)
|
|
||||||
- 更完整的真机验证覆盖
|
|
||||||
- `reincarnate.py` 的实跑验证
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 文档索引
|
|
||||||
|
|
||||||
全部在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs):
|
|
||||||
|
|
||||||
| 文档 | 内容 |
|
|
||||||
|---|---|
|
|
||||||
| [项目总览](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/项目总览.md) | 开发向主文档,该放哪 / 该怎么写 |
|
|
||||||
| [主脚本编写指南](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/主脚本编写指南.md) | 写新主脚本的规范 |
|
|
||||||
| [上传成绩开发文档](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/上传成绩开发文档.md) | `UpsertUserAll` 的 36 节点 / 110 字段 |
|
|
||||||
| [UpsertUserAll踩坑清单](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/UpsertUserAll踩坑清单.md) | 28 条踩坑记录 |
|
|
||||||
| [功能票使用说明](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/功能票使用说明.md) | 买票 / 用票的完整原理与报文依据 |
|
|
||||||
| [账号转生说明](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/账号转生说明.md) | `reincarnate.py` 完整流程 |
|
|
||||||
| [netdelivery说明](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/netdelivery说明.md) | DLI 协议与运行原理 |
|
|
||||||
| [常见坑与注意事项](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/常见坑与注意事项.md) | 二维码 / 账号状态 / 副作用 / 日志 / 网络 |
|
|
||||||
| [购买功能票流程](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/购买功能票流程.md) | 买票接口的完整排查过程 |
|
|
||||||
| [购买功能票_存档与交接](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/购买功能票_存档与交接.md) | 排查存档 |
|
|
||||||
| [真机Hook对照报告](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/真机Hook对照报告.md) | 抓包 Mod 对照 |
|
|
||||||
| [实跑验证记录](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/实跑验证记录.md) | 每次实跑的结果 |
|
|
||||||
| [舞萌DX_扫码登录协议技术规范](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/舞萌DX_扫码登录协议技术规范.md) | aimedb 扫码协议 |
|
|
||||||
| [日志与主脚本指南](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/日志与主脚本指南.md) | 日志系统说明 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 特别鸣谢
|
|
||||||
|
|
||||||
- **志成🍥** —— 提供脱壳 DLL
|
|
||||||
- [**WorkBuddy AI**](https://www.workbuddy.ai/)—— 提供 AI 模型
|
|
||||||
- **DeepSeek V4.1 Flash** —— 主要代码编写
|
|
||||||
- [**NVIDIA NIM**](https://build.nvidia.com/models)、
|
|
||||||
[**AMD**](https://developer.amd.com.cn/radeon/tokenfactory)、
|
|
||||||
[**Kilo**](https://kilo.ai/)—— 提供 AI 模型
|
|
||||||
- [**eaquira**](https://git.fragrance.moe/Fragrance/eaquira)—— 思路启发
|
|
||||||
- **神秘群U** —— 私有科技的 Web 启发(暂无)
|
|
||||||
- **香水所有群友** —— 和谐帮助
|
|
||||||
|
|
||||||
*(还有什么以后补充)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 免责声明
|
|
||||||
|
|
||||||
本项目仅供**学习与协议研究**使用。使用本工具产生的任何后果由使用者自行承担。
|
|
||||||
请遵守游戏服务条款,不要用于破坏游戏公平性。
|
|
||||||
-530
@@ -1,530 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
api 包 —— 统一导出
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
主脚本只需要 from api import ... 就能拿到全部功能。
|
|
||||||
|
|
||||||
★ 本文件在导入时就给下面 _API_FUNCS 里列出的每个函数套上「日志外壳」,
|
|
||||||
所以主脚本拿到的已经是带日志的版本 —— 调用时会自动产出:
|
|
||||||
|
|
||||||
=== ParseQRCode 开始运行 ===
|
|
||||||
[接口说明] 解析二维码并换取 userId 与 token。
|
|
||||||
[位置参数] 'SGWCMAID2610022219463A297AF4CA...'
|
|
||||||
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
|
|
||||||
=== ParseQRCode 运行结束 ===
|
|
||||||
|
|
||||||
主脚本**不需要**写装饰器,也不需要自己埋点。
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
目录结构(一个 api 一个脚本)
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
基础设施
|
|
||||||
log_setup.py 统一日志(INFO / DEBUG 双模式)
|
|
||||||
maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
|
|
||||||
user_getter_common.py 取数脚本公共底座(请求 VO / _get / 翻页)
|
|
||||||
|
|
||||||
会话链路
|
|
||||||
chime.py 扫码登录 -> userId + token
|
|
||||||
get_game_settings.py GetGameSettingApi(游戏设置)
|
|
||||||
ping.py 心跳探活
|
|
||||||
get_user_preview.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 ★ 结算快照编排(统一调用下面的取数脚本),
|
|
||||||
并把结果落盘到 <工程根>/data/<userId>.json
|
|
||||||
|
|
||||||
★ 命名约定:服务端 API 名里带 Get 的,**脚本名与函数名一律带 get / Get**
|
|
||||||
(不要模棱两可)。所以下面这些文件全是 get_ 开头。
|
|
||||||
|
|
||||||
取数 —— GetUser*(一个接口一个脚本)
|
|
||||||
get_user_data.py GetUserDataApi
|
|
||||||
get_user_extend.py GetUserExtendApi
|
|
||||||
get_user_option.py GetUserOptionApi
|
|
||||||
get_user_character.py GetUserCharacterApi
|
|
||||||
get_user_ghost.py GetUserGhostApi
|
|
||||||
get_user_map.py GetUserMapApi
|
|
||||||
get_user_login_bonus.py GetUserLoginBonusApi
|
|
||||||
get_user_rating.py GetUserRatingApi
|
|
||||||
get_user_item.py GetUserItemApi
|
|
||||||
get_user_music.py GetUserMusicApi
|
|
||||||
get_user_course.py GetUserCourseApi
|
|
||||||
get_user_charge.py GetUserChargeApi
|
|
||||||
get_user_favorite.py GetUserFavoriteApi
|
|
||||||
get_user_activity.py GetUserActivityApi
|
|
||||||
get_user_mission.py GetUserMissionDataApi
|
|
||||||
get_user_intimate.py GetUserIntimateApi
|
|
||||||
get_user_shop_stock.py GetUserShopStockApi
|
|
||||||
get_user_favorite_item.py GetUserFavoriteItemApi
|
|
||||||
get_user_rival.py GetUserRivalDataApi / GetUserRivalMusicApi
|
|
||||||
get_user_friend_bonus.py GetUserFriendBonusApi
|
|
||||||
get_user_kaleidx_scope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi
|
|
||||||
get_user_region.py GetUserRegionApi
|
|
||||||
get_user_recommend_music.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi
|
|
||||||
get_user_new_item.py GetUserNewItemListApi
|
|
||||||
|
|
||||||
取数 —— GetGame*
|
|
||||||
get_game_settings.py GetGameSettingApi
|
|
||||||
get_game_charge.py GetGameChargeApi
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .log_setup import (
|
|
||||||
# 会话
|
|
||||||
setup_logging,
|
|
||||||
teardown_logging,
|
|
||||||
# 自动跟踪
|
|
||||||
autowrap,
|
|
||||||
get_called_apis,
|
|
||||||
# 模式
|
|
||||||
get_level,
|
|
||||||
set_level,
|
|
||||||
is_debug,
|
|
||||||
LEVEL_INFO,
|
|
||||||
LEVEL_DEBUG,
|
|
||||||
# 装饰器 / 兼容
|
|
||||||
api_log,
|
|
||||||
get_logger,
|
|
||||||
# 流程日志(INFO 可见)
|
|
||||||
log_info,
|
|
||||||
log_step,
|
|
||||||
log_result,
|
|
||||||
log_warning,
|
|
||||||
log_error,
|
|
||||||
log_section,
|
|
||||||
log_field,
|
|
||||||
log_debug,
|
|
||||||
# 收发数据日志(DEBUG 专属)
|
|
||||||
log_request,
|
|
||||||
log_response,
|
|
||||||
log_payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1) 网络核心
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
from .maimai_net import (
|
|
||||||
# 协议常量(原在 config.py,已下沉到 maimai_net.py)
|
|
||||||
BASE_URL,
|
|
||||||
AES_KEY,
|
|
||||||
AES_IV,
|
|
||||||
OBFUSCATE_PARAM,
|
|
||||||
MaimaiNetClient,
|
|
||||||
MaimaiNetError,
|
|
||||||
get_default_client,
|
|
||||||
close_default_client,
|
|
||||||
request,
|
|
||||||
request_dict,
|
|
||||||
request_vo,
|
|
||||||
obfuscate_api_name,
|
|
||||||
build_api_url,
|
|
||||||
build_user_agent,
|
|
||||||
adler32,
|
|
||||||
aes_encrypt,
|
|
||||||
aes_decrypt,
|
|
||||||
deflate_compress,
|
|
||||||
deflate_decompress,
|
|
||||||
vo_to_json,
|
|
||||||
vo_from_json,
|
|
||||||
local_utc_offset_seconds,
|
|
||||||
login_date_time_now,
|
|
||||||
EmptyRequestVO,
|
|
||||||
UserIdRequestVO,
|
|
||||||
ReturnCodeVO,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2) 会话链路
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
from .chime import (
|
|
||||||
ParseQRCode,
|
|
||||||
# aimedb / QR 常量(原在 config.py,已下沉到 chime.py)
|
|
||||||
AIMEDB_HOST,
|
|
||||||
AIMEDB_PORT,
|
|
||||||
AIMEDB_PATH,
|
|
||||||
QR_SIGN_KEY,
|
|
||||||
QR_OPEN_GAME_ID,
|
|
||||||
QR_TITLE_KEY,
|
|
||||||
QR_USER_AGENT,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .get_game_settings import (
|
|
||||||
GetGameSettings,
|
|
||||||
GameSetting,
|
|
||||||
GameSettingRequestVO,
|
|
||||||
GameSettingResponseVO,
|
|
||||||
is_maintenance,
|
|
||||||
is_ping_disabled,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .ping import Ping
|
|
||||||
|
|
||||||
from .get_user_preview import (
|
|
||||||
GetUserPreview,
|
|
||||||
UserPreviewRequestVO,
|
|
||||||
UserPreviewResponseVO,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .userlogin import (
|
|
||||||
UserLogin,
|
|
||||||
UserLoginRequestVO,
|
|
||||||
UserLoginResponseVO,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .userlogout import (
|
|
||||||
UserLogout,
|
|
||||||
LogoutType,
|
|
||||||
DelayLog,
|
|
||||||
RequestAPI,
|
|
||||||
UserLogoutRequestVO,
|
|
||||||
UserLogoutRequest2VO,
|
|
||||||
UserLogoutResponseVO,
|
|
||||||
create_empty_delay_log,
|
|
||||||
create_delay_log_from_stats,
|
|
||||||
DELAY_LOG_SLOTS,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3) 上传成绩
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
from .upsert_user_all import (
|
|
||||||
UpsertUserAll,
|
|
||||||
PlayData,
|
|
||||||
build_play_from_config,
|
|
||||||
derive_play_characters,
|
|
||||||
derive_play_rating,
|
|
||||||
derive_play_grade,
|
|
||||||
find_music_detail,
|
|
||||||
PLAY_SPECIAL_NUM,
|
|
||||||
PLAY_LOCAL_PLAY_COUNT,
|
|
||||||
PLAY_AFTER_GRADE_RANK,
|
|
||||||
DERIVE_GRADE_FROM_MUSIC_DETAIL,
|
|
||||||
UserAllRequestVO,
|
|
||||||
UpsertResponseVO,
|
|
||||||
UpsertUserAllError,
|
|
||||||
ConfirmRequiredError,
|
|
||||||
PatchError,
|
|
||||||
# 节点结构
|
|
||||||
NODE_TYPES,
|
|
||||||
NODE_ORDER,
|
|
||||||
BITS_TO_ARRAY,
|
|
||||||
ARRAY_TO_BITS,
|
|
||||||
DIFF_PRIMARY_KEYS,
|
|
||||||
MAY_BE_EMPTY_NODES,
|
|
||||||
DEFAULT_EMPTY_NODES,
|
|
||||||
# 组包辅助
|
|
||||||
empty_node,
|
|
||||||
empty_user_all,
|
|
||||||
coerce_node_value,
|
|
||||||
sync_bits_to_arrays,
|
|
||||||
build_delta,
|
|
||||||
build_favorite_delta,
|
|
||||||
build_user_all,
|
|
||||||
build_user_playlog,
|
|
||||||
build_user_game_playlog,
|
|
||||||
build_post_play_user_data,
|
|
||||||
validate_user_playlog,
|
|
||||||
validate_before_send,
|
|
||||||
apply_play_effects,
|
|
||||||
# ★ 额外 JSON 合并 / 落库回查
|
|
||||||
load_extra_data,
|
|
||||||
merge_extra_data,
|
|
||||||
verify_upsert_landing,
|
|
||||||
default_user_weekly_data,
|
|
||||||
default_user_2p_playlog,
|
|
||||||
calc_play_special,
|
|
||||||
normalize_snapshot,
|
|
||||||
wait_send_cooldown,
|
|
||||||
# 字段表
|
|
||||||
PLAYLOG_FIELDS,
|
|
||||||
PLAYLOG_BOOL_FIELDS,
|
|
||||||
PLAYLOG_STRING_FIELDS,
|
|
||||||
JUDGE_TYPES,
|
|
||||||
JUDGE_BOXES,
|
|
||||||
USER_DATA_DROP_KEYS,
|
|
||||||
USER_OPTION_DROP_KEYS,
|
|
||||||
USER_CHARACTER_KEEP_KEYS,
|
|
||||||
USER_MUSIC_DETAIL_DROP_KEYS,
|
|
||||||
USER_CHARGE_DROP_KEYS,
|
|
||||||
USER_MISSION_DROP_KEYS,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .get_need_user_data import (
|
|
||||||
GetNeedUserData,
|
|
||||||
save_snapshot,
|
|
||||||
load_snapshot,
|
|
||||||
summarize_snapshot,
|
|
||||||
DEFAULT_SNAPSHOT_NODES,
|
|
||||||
# ★ 用户数据落盘(<工程根>/data/<userId>.json)
|
|
||||||
PROJECT_ROOT,
|
|
||||||
DATA_DIR,
|
|
||||||
user_data_path,
|
|
||||||
save_user_data,
|
|
||||||
load_user_data,
|
|
||||||
list_saved_user_ids,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .upsert_user_chargelog import (
|
|
||||||
# 票券主数据(原在 config.py,已下沉到 upsert_user_chargelog.py)
|
|
||||||
TICKET_CREDIT_NUM,
|
|
||||||
TICKET_NAMES,
|
|
||||||
PAID_TICKET_IDS,
|
|
||||||
TICKET_VALID_HOUR_OFFSET,
|
|
||||||
TICKET_VALID_DAYS,
|
|
||||||
UpsertUserChargelog,
|
|
||||||
ChargelogConfirmError,
|
|
||||||
ChargelogGuardError,
|
|
||||||
UserChargeVO,
|
|
||||||
UserChargelogVO,
|
|
||||||
UserChargelogRequestVO,
|
|
||||||
ticket_name,
|
|
||||||
ticket_price,
|
|
||||||
calc_valid_date,
|
|
||||||
parse_wall_time,
|
|
||||||
now_cst,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4) 取数公共底座
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
from .user_getter_common import (
|
|
||||||
EmptyRequestVO as GetterEmptyRequestVO,
|
|
||||||
UserIdRequestVO as GetterUserIdRequestVO,
|
|
||||||
UserIdPageRequestVO,
|
|
||||||
UserLoginBonusRequestVO,
|
|
||||||
UserItemRequestVO,
|
|
||||||
UserMusicRequestVO,
|
|
||||||
UserCourseRequestVO,
|
|
||||||
UserFavoriteRequestVO,
|
|
||||||
UserFavoriteItemRequestVO,
|
|
||||||
UserShopStockRequestVO,
|
|
||||||
UserRivalRequestVO,
|
|
||||||
UserRivalMusicRequestVO,
|
|
||||||
get_all_pages,
|
|
||||||
ITEM_KINDS,
|
|
||||||
ITEM_NEXT_INDEX_FACTOR,
|
|
||||||
FAVORITE_ITEM_KINDS,
|
|
||||||
RIVAL_MUSIC_LEVELS,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 5) 取数脚本(一个接口一个脚本)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
from .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 (
|
|
||||||
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 .get_game_charge import GetGameCharge, GameChargeRequestVO
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_API_FUNCS = (
|
|
||||||
# --- 会话链路 ---
|
|
||||||
"ParseQRCode",
|
|
||||||
"GetGameSettings",
|
|
||||||
"Ping",
|
|
||||||
"GetUserPreview",
|
|
||||||
"UserLogin",
|
|
||||||
"UserLogout",
|
|
||||||
# --- 上传成绩 ---
|
|
||||||
"UpsertUserAll",
|
|
||||||
"build_play_from_config",
|
|
||||||
"derive_play_characters",
|
|
||||||
"merge_extra_data",
|
|
||||||
"verify_upsert_landing",
|
|
||||||
"build_user_all",
|
|
||||||
# --- 购买功能票 ---
|
|
||||||
"UpsertUserChargelog",
|
|
||||||
"ticket_name",
|
|
||||||
"ticket_price",
|
|
||||||
"calc_valid_date",
|
|
||||||
"build_user_playlog",
|
|
||||||
"build_user_game_playlog",
|
|
||||||
"validate_user_playlog",
|
|
||||||
"validate_before_send",
|
|
||||||
"apply_play_effects",
|
|
||||||
"build_post_play_user_data",
|
|
||||||
"build_delta",
|
|
||||||
"build_favorite_delta",
|
|
||||||
"normalize_snapshot",
|
|
||||||
"GetNeedUserData",
|
|
||||||
"summarize_snapshot",
|
|
||||||
"GetUserNewItemList",
|
|
||||||
# --- 取数:单接口 ---
|
|
||||||
"GetUserData", # GetUserDataApi
|
|
||||||
"GetUserExtend", # GetUserExtendApi
|
|
||||||
"GetUserOption", # GetUserOptionApi
|
|
||||||
"GetUserCharacter", # GetUserCharacterApi
|
|
||||||
"GetUserGhost", # GetUserGhostApi
|
|
||||||
"GetUserMap", # GetUserMapApi(单页)
|
|
||||||
"GetUserMapAll", # GetUserMapApi(全量)
|
|
||||||
"GetUserLoginBonus", # GetUserLoginBonusApi(单页)
|
|
||||||
"GetUserLoginBonusAll", # GetUserLoginBonusApi(全量)
|
|
||||||
"GetUserRating", # GetUserRatingApi
|
|
||||||
"GetUserItem", # GetUserItemApi(单类)
|
|
||||||
"GetUserItemAll", # GetUserItemApi(全量)
|
|
||||||
"GetUserMusicDetail", # GetUserMusicApi(单页)
|
|
||||||
"GetUserMusicDetailAll", # GetUserMusicApi(全量)
|
|
||||||
"GetUserCourse", # GetUserCourseApi(单页)
|
|
||||||
"GetUserCourseAll", # GetUserCourseApi(全量)
|
|
||||||
"GetUserCharge", # GetUserChargeApi
|
|
||||||
"GetUserFavorite", # GetUserFavoriteApi(单类)
|
|
||||||
"GetUserFavoriteAll", # GetUserFavoriteApi(全量)
|
|
||||||
"GetUserActivity", # GetUserActivityApi
|
|
||||||
"GetUserMissionData", # GetUserMissionDataApi
|
|
||||||
"GetUserIntimate", # GetUserIntimateApi
|
|
||||||
"GetUserShopStock", # GetUserShopStockApi
|
|
||||||
"GetUserFavoriteItem", # GetUserFavoriteItemApi(单页)
|
|
||||||
"GetUserFavoriteItemAll", # GetUserFavoriteItemApi(全量)
|
|
||||||
"GetUserRivalData", # GetUserRivalDataApi
|
|
||||||
"GetUserRivalMusic", # GetUserRivalMusicApi(单页)
|
|
||||||
"GetUserRivalMusicAll", # GetUserRivalMusicApi(全量)
|
|
||||||
"GetUserFriendBonus", # GetUserFriendBonusApi
|
|
||||||
"GetUserKaleidxScope", # GetUserKaleidxScopeApi
|
|
||||||
"GetGameKaleidxScope", # GetGameKaleidxScopeApi
|
|
||||||
"GetUserRegion", # GetUserRegionApi
|
|
||||||
"GetUserRecommendRateMusic", # GetUserRecommendRateMusicApi
|
|
||||||
"GetUserRecommendSelectMusic", # GetUserRecommendSelectMusicApi
|
|
||||||
"GetUserNewItemList", # GetUserNewItemListApi
|
|
||||||
# --- 取数:游戏侧 ---
|
|
||||||
"GetGameCharge", # GetGameChargeApi
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 7) 导入即包装:保证 from api import X 拿到的是带日志的版本
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
autowrap(globals(), _API_FUNCS)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 8) 统一导出
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
# ---- 日志:会话 ----
|
|
||||||
"setup_logging", "teardown_logging",
|
|
||||||
# ---- 日志:自动跟踪 ----
|
|
||||||
"autowrap", "get_called_apis",
|
|
||||||
# ---- 日志:模式 ----
|
|
||||||
"get_level", "set_level", "is_debug", "LEVEL_INFO", "LEVEL_DEBUG",
|
|
||||||
# ---- 日志:装饰器 / 兼容 ----
|
|
||||||
"api_log", "get_logger",
|
|
||||||
# ---- 日志:流程 ----
|
|
||||||
"log_info", "log_step", "log_result", "log_warning", "log_error",
|
|
||||||
"log_section", "log_field",
|
|
||||||
# ---- 日志:收发数据 ----
|
|
||||||
"log_debug", "log_request", "log_response", "log_payload",
|
|
||||||
# ---- 网络核心 ----
|
|
||||||
"MaimaiNetClient", "MaimaiNetError", "get_default_client", "close_default_client",
|
|
||||||
"request", "request_dict", "request_vo",
|
|
||||||
"obfuscate_api_name", "build_api_url", "build_user_agent", "adler32",
|
|
||||||
"aes_encrypt", "aes_decrypt", "deflate_compress", "deflate_decompress",
|
|
||||||
"vo_to_json", "vo_from_json",
|
|
||||||
"local_utc_offset_seconds", "login_date_time_now",
|
|
||||||
"EmptyRequestVO", "UserIdRequestVO", "ReturnCodeVO",
|
|
||||||
# ---- 会话链路 ----
|
|
||||||
"ParseQRCode",
|
|
||||||
"GetGameSettings", "GameSetting", "GameSettingRequestVO", "GameSettingResponseVO",
|
|
||||||
"is_maintenance", "is_ping_disabled",
|
|
||||||
"Ping",
|
|
||||||
"GetUserPreview", "UserPreviewRequestVO", "UserPreviewResponseVO",
|
|
||||||
"UserLogin", "UserLoginRequestVO", "UserLoginResponseVO",
|
|
||||||
"UserLogout", "LogoutType", "DelayLog", "RequestAPI",
|
|
||||||
"UserLogoutRequestVO", "UserLogoutRequest2VO", "UserLogoutResponseVO",
|
|
||||||
"create_empty_delay_log", "create_delay_log_from_stats", "DELAY_LOG_SLOTS",
|
|
||||||
# ---- 上传成绩 ----
|
|
||||||
"UpsertUserAll", "PlayData", "build_play_from_config", "derive_play_characters",
|
|
||||||
"load_extra_data", "merge_extra_data", "verify_upsert_landing",
|
|
||||||
# ---- 购买功能票 ----
|
|
||||||
"UpsertUserChargelog", "ChargelogConfirmError", "ChargelogGuardError",
|
|
||||||
"UserChargeVO", "UserChargelogVO", "UserChargelogRequestVO",
|
|
||||||
"ticket_name", "ticket_price", "calc_valid_date", "parse_wall_time", "now_cst",
|
|
||||||
"UserAllRequestVO", "UpsertResponseVO",
|
|
||||||
"UpsertUserAllError", "ConfirmRequiredError", "PatchError",
|
|
||||||
"NODE_TYPES", "NODE_ORDER", "BITS_TO_ARRAY", "ARRAY_TO_BITS",
|
|
||||||
"DIFF_PRIMARY_KEYS", "MAY_BE_EMPTY_NODES", "DEFAULT_EMPTY_NODES",
|
|
||||||
"empty_node", "empty_user_all", "coerce_node_value", "sync_bits_to_arrays",
|
|
||||||
"build_delta", "build_favorite_delta", "build_user_all",
|
|
||||||
"build_user_playlog", "build_user_game_playlog", "build_post_play_user_data",
|
|
||||||
"validate_user_playlog", "validate_before_send", "apply_play_effects",
|
|
||||||
"default_user_weekly_data", "default_user_2p_playlog", "calc_play_special",
|
|
||||||
"normalize_snapshot", "wait_send_cooldown",
|
|
||||||
"PLAYLOG_FIELDS", "PLAYLOG_BOOL_FIELDS", "PLAYLOG_STRING_FIELDS",
|
|
||||||
"JUDGE_TYPES", "JUDGE_BOXES",
|
|
||||||
"USER_DATA_DROP_KEYS", "USER_OPTION_DROP_KEYS", "USER_CHARACTER_KEEP_KEYS",
|
|
||||||
"USER_MUSIC_DETAIL_DROP_KEYS", "USER_CHARGE_DROP_KEYS", "USER_MISSION_DROP_KEYS",
|
|
||||||
# ---- 快照编排 ----
|
|
||||||
"GetNeedUserData", "save_snapshot", "load_snapshot",
|
|
||||||
"summarize_snapshot", "DEFAULT_SNAPSHOT_NODES",
|
|
||||||
"PROJECT_ROOT", "DATA_DIR", "user_data_path",
|
|
||||||
"save_user_data", "load_user_data", "list_saved_user_ids",
|
|
||||||
# ---- 取数公共底座 ----
|
|
||||||
"GetterEmptyRequestVO", "GetterUserIdRequestVO", "UserIdPageRequestVO",
|
|
||||||
"UserLoginBonusRequestVO", "UserItemRequestVO", "UserMusicRequestVO",
|
|
||||||
"UserCourseRequestVO", "UserFavoriteRequestVO", "UserFavoriteItemRequestVO",
|
|
||||||
"UserShopStockRequestVO", "UserRivalRequestVO", "UserRivalMusicRequestVO",
|
|
||||||
"get_all_pages",
|
|
||||||
"ITEM_KINDS", "ITEM_NEXT_INDEX_FACTOR", "FAVORITE_ITEM_KINDS", "RIVAL_MUSIC_LEVELS",
|
|
||||||
# ---- 取数脚本 ----
|
|
||||||
"GetUserData", "GetUserExtend", "GetUserOption", "GetUserCharacter", "GetUserGhost",
|
|
||||||
"GetUserMap", "GetUserMapAll",
|
|
||||||
"GetUserLoginBonus", "GetUserLoginBonusAll",
|
|
||||||
"GetUserRating",
|
|
||||||
"GetUserItem", "GetUserItemAll",
|
|
||||||
"GetUserMusicDetail", "GetUserMusicDetailAll",
|
|
||||||
"GetUserCourse", "GetUserCourseAll",
|
|
||||||
"GetUserCharge",
|
|
||||||
"GetUserFavorite", "GetUserFavoriteAll",
|
|
||||||
"GetUserActivity", "GetUserMissionData", "GetUserIntimate", "GetUserShopStock",
|
|
||||||
"GetUserFavoriteItem", "GetUserFavoriteItemAll",
|
|
||||||
"FAVORITE_ITEM_KIND_MUSIC", "FAVORITE_ITEM_KIND_RIVAL",
|
|
||||||
"GetUserRivalData", "GetUserRivalMusic", "GetUserRivalMusicAll",
|
|
||||||
"GetUserFriendBonus", "extract_gift_points",
|
|
||||||
"GetUserKaleidxScope", "GetGameKaleidxScope",
|
|
||||||
"GetUserRegion",
|
|
||||||
"GetUserRecommendRateMusic", "GetUserRecommendSelectMusic",
|
|
||||||
"GetUserNewItemList", "UserNewItemListRequestVO",
|
|
||||||
"GetGameCharge", "GameChargeRequestVO",
|
|
||||||
# --- 协议常量(原在 config.py,已下沉到各 api 模块)---
|
|
||||||
"BASE_URL", "AES_KEY", "AES_IV", "OBFUSCATE_PARAM",
|
|
||||||
"AIMEDB_HOST", "AIMEDB_PORT", "AIMEDB_PATH",
|
|
||||||
"QR_SIGN_KEY", "QR_OPEN_GAME_ID", "QR_TITLE_KEY", "QR_USER_AGENT",
|
|
||||||
"TICKET_CREDIT_NUM", "TICKET_NAMES", "PAID_TICKET_IDS",
|
|
||||||
"TICKET_VALID_HOUR_OFFSET", "TICKET_VALID_DAYS",
|
|
||||||
]
|
|
||||||
-311
@@ -1,311 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
chime.py —— 扫码登录(二维码 -> userId + token)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
功能:把微信二维码串(或二维码 URL)交给 aimedb 服务器,换取 userId 与 token。
|
|
||||||
|
|
||||||
对应接口:POST https://ai.sys-allnet.cn/wc_aime/api/get_data
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
协议依据
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
|
|
||||||
CCommGetUserData_Create(strGameID, strChipID, strCommonKey,
|
|
||||||
strQRData, strTitleKey, serverURLIndex)
|
|
||||||
strGameID = "MAID"
|
|
||||||
strChipID = KeychipID
|
|
||||||
strQRData = 二维码串[20:] <- 见 CCommQRCode.EraseHashIdentifier()
|
|
||||||
strTitleKey= "SDGB"
|
|
||||||
|
|
||||||
source/ChimeLib.NET/ChimeLib/NET/COMM/CCommQRCode.cs
|
|
||||||
IsValid() : 前缀 "SGWC"(Chime.WechatID) + 偏移 4 处为 gameID
|
|
||||||
GetTimeStamp() : [8:20] 12 位 yymmddHHMMss
|
|
||||||
EraseHashIdentifier() : [20:] 64 位载荷
|
|
||||||
|
|
||||||
source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250-255
|
|
||||||
KeychipID = AMDaemon.System.KeychipId.Value
|
|
||||||
TitleKey = AMDaemon.System.GameId
|
|
||||||
GameID = "MAID"
|
|
||||||
|
|
||||||
docs/舞萌DX_扫码登录协议技术规范.md
|
|
||||||
- 签名 key = SHA-256(chipID + timestamp + SIGN_KEY) 大写 hex
|
|
||||||
- ★ 请求头【绝不能】带 Content-Type,否则服务端返回 errorID=57
|
|
||||||
- qrCode 字段传的是 64 位载荷,不是完整 84 字符串
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
调用示例
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
from api import ParseQRCode
|
|
||||||
|
|
||||||
user_id, token = ParseQRCode("SGWCMAID2610022219463A...")
|
|
||||||
# 也可以直接传二维码 URL
|
|
||||||
user_id, token = ParseQRCode("https://wq.wahlap.net/qrcode/req/MAID261002....html?l=...")
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import http.client
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import ssl
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Tuple
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from config import KEYCHIP_ID
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# aimedb 扫码登录常量(原在 config.py,2026-10-06 下沉到本模块)
|
|
||||||
# =============================================================================
|
|
||||||
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
|
|
||||||
# + docs 分支《舞萌DX_扫码登录协议技术规范》
|
|
||||||
|
|
||||||
#: aimedb 服务器
|
|
||||||
AIMEDB_HOST = "ai.sys-allnet.cn"
|
|
||||||
AIMEDB_PORT = 443
|
|
||||||
AIMEDB_PATH = "/wc_aime/api/get_data"
|
|
||||||
|
|
||||||
#: 签名密钥(含前导 X,32 字符)
|
|
||||||
#: 来源:chimelib_dll.dll .rdata UTF-16 @0x00B040,抓包验证一致
|
|
||||||
QR_SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
|
|
||||||
|
|
||||||
#: 固定字段
|
|
||||||
QR_OPEN_GAME_ID = "MAID"
|
|
||||||
QR_TITLE_KEY = "SDGB"
|
|
||||||
QR_USER_AGENT = "WC_AIME_LIB"
|
|
||||||
|
|
||||||
from .log_setup import log_debug, log_error, log_info, log_request, log_response
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 二维码格式常量(CCommQRCode.cs + 技术规范)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
QR_PREFIX = "SGWC" # Chime.WechatID
|
|
||||||
QR_GAME_ID = "MAID"
|
|
||||||
QR_PREFIX_LEN = 20 # SGWC(4) + MAID(4) + timestamp(12)
|
|
||||||
QR_PAYLOAD_LEN = 64
|
|
||||||
QR_TOTAL_LEN = QR_PREFIX_LEN + QR_PAYLOAD_LEN # 84
|
|
||||||
|
|
||||||
#: 默认机台 chipID
|
|
||||||
#:
|
|
||||||
#: ★★ 必须用**完整形式**(带连字符),不是短值 —— 这是个容易搞错的点:
|
|
||||||
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
|
|
||||||
#: KeychipID = AMDaemon.System.KeychipId.Value; // 完整形式
|
|
||||||
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:255
|
|
||||||
#: Chime.Instance.Init(GameID, KeychipID, _chimeDevice, TitleKey);
|
|
||||||
#: 而游戏服务器那边用的是短值:
|
|
||||||
#: source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
|
|
||||||
#: clientId = AMDaemon.System.KeychipId.ShortValue; // 短值
|
|
||||||
#:
|
|
||||||
#: 即:aimedb 扫码用完整 KeychipId,游戏服务器用 ShortValue。
|
|
||||||
#: docs 分支的《扫码登录协议技术规范》§3.4 里也有对应的抓包验证向量。
|
|
||||||
#: token 与 chipID 绑定,用错会导致后续接口「returnCode=1 但不落库」。
|
|
||||||
DEFAULT_CHIP_ID = KEYCHIP_ID
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 内部工具
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def _extract_qr_from_url(url: str) -> str:
|
|
||||||
"""
|
|
||||||
从二维码 URL 提取完整 84 字符串。
|
|
||||||
|
|
||||||
URL 形如:
|
|
||||||
https://wq.wahlap.net/qrcode/req/MAID2610022219463A....html?l=...&t=...&d=...
|
|
||||||
取 /qrcode/req/ 与 .html 之间的 80 字符,前面补 "SGWC"。
|
|
||||||
"""
|
|
||||||
path = urlparse(url).path
|
|
||||||
match = re.search(r"/qrcode/req/([^/]+)\.html", path)
|
|
||||||
if not match:
|
|
||||||
raise ValueError("URL 路径格式不符合预期,无法提取二维码数据:" + url)
|
|
||||||
return QR_PREFIX + match.group(1)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_qr(qr_input: str) -> str:
|
|
||||||
"""URL 或字符串 -> 标准 84 字符二维码串(含格式校验)。"""
|
|
||||||
qr = (qr_input or "").strip()
|
|
||||||
|
|
||||||
if qr.startswith("http://") or qr.startswith("https://"):
|
|
||||||
log_debug("输入是 URL,先提取二维码字符串")
|
|
||||||
qr = _extract_qr_from_url(qr)
|
|
||||||
|
|
||||||
if len(qr) != QR_TOTAL_LEN:
|
|
||||||
raise ValueError(
|
|
||||||
"二维码长度应为 " + str(QR_TOTAL_LEN) + " 字符,实际 " + str(len(qr)) + " 字符:" + qr
|
|
||||||
)
|
|
||||||
if not qr.startswith(QR_PREFIX):
|
|
||||||
raise ValueError("二维码必须以 '" + QR_PREFIX + "' 开头:" + qr)
|
|
||||||
if qr[4:8] != QR_GAME_ID:
|
|
||||||
raise ValueError(
|
|
||||||
"二维码 GameID 应为 '" + QR_GAME_ID + "',实际 '" + qr[4:8] + "':" + qr
|
|
||||||
)
|
|
||||||
if not all(c in "0123456789ABCDEF" for c in qr[QR_PREFIX_LEN:]):
|
|
||||||
raise ValueError("二维码载荷必须为大写十六进制:" + qr)
|
|
||||||
|
|
||||||
log_debug(
|
|
||||||
"二维码校验通过:timestamp=" + qr[8:20]
|
|
||||||
+ " payload_len=" + str(len(qr[QR_PREFIX_LEN:]))
|
|
||||||
)
|
|
||||||
return qr
|
|
||||||
|
|
||||||
|
|
||||||
def _build_body(qr_string: str, chip_id: str) -> Tuple[bytes, str, str]:
|
|
||||||
"""
|
|
||||||
构造请求体。
|
|
||||||
|
|
||||||
签名(技术规范 §4.1):
|
|
||||||
key = SHA-256(chipID + timestamp + SIGN_KEY).hexdigest().upper()
|
|
||||||
★ 拼接里不含 qrCode、不含 titlekey
|
|
||||||
|
|
||||||
qrCode 字段(CCommQRCode.EraseHashIdentifier):
|
|
||||||
qr_string[20:] 即 64 位载荷
|
|
||||||
|
|
||||||
返回:
|
|
||||||
(body_bytes, timestamp, key)
|
|
||||||
"""
|
|
||||||
timestamp = datetime.now().strftime("%y%m%d%H%M%S")
|
|
||||||
key = hashlib.sha256((chip_id + timestamp + QR_SIGN_KEY).encode()).hexdigest().upper()
|
|
||||||
|
|
||||||
body_dict = {
|
|
||||||
"chipID": chip_id,
|
|
||||||
"openGameID": QR_OPEN_GAME_ID,
|
|
||||||
"key": key,
|
|
||||||
"qrCode": qr_string[QR_PREFIX_LEN:],
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"titlekey": QR_TITLE_KEY,
|
|
||||||
}
|
|
||||||
body_bytes = json.dumps(body_dict, separators=(",", ":")).encode("utf-8")
|
|
||||||
return body_bytes, timestamp, key
|
|
||||||
|
|
||||||
|
|
||||||
def _send(body_bytes: bytes) -> dict:
|
|
||||||
"""
|
|
||||||
发送 HTTPS POST 并解析 JSON 响应。
|
|
||||||
|
|
||||||
★ 请求头只放 Connection / User-Agent / Content-Length。
|
|
||||||
绝不能加 Content-Type —— 加了会得到 errorID=57(技术规范 §3.3)。
|
|
||||||
"""
|
|
||||||
ctx = ssl.create_default_context()
|
|
||||||
ctx.check_hostname = False
|
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Connection": "Keep-Alive",
|
|
||||||
"User-Agent": QR_USER_AGENT,
|
|
||||||
"Content-Length": str(len(body_bytes)),
|
|
||||||
# ★ 此处刻意不加 Content-Type
|
|
||||||
}
|
|
||||||
|
|
||||||
conn = http.client.HTTPSConnection(AIMEDB_HOST, AIMEDB_PORT, context=ctx, timeout=20)
|
|
||||||
try:
|
|
||||||
conn.request("POST", AIMEDB_PATH, body=body_bytes, headers=headers)
|
|
||||||
resp = conn.getresponse()
|
|
||||||
text = resp.read().decode("utf-8")
|
|
||||||
status = resp.status
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
log_response(status=status, body=text, api_name="aimedb/get_data")
|
|
||||||
try:
|
|
||||||
return json.loads(text)
|
|
||||||
except Exception as exc:
|
|
||||||
raise RuntimeError("aimedb 响应不是合法 JSON:" + text[:300]) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _parse(resp: dict) -> Tuple[int, str]:
|
|
||||||
"""解析响应,返回 (user_id, token)。"""
|
|
||||||
error_id = resp.get("errorID", -1)
|
|
||||||
user_id = resp.get("userID", -1)
|
|
||||||
token = resp.get("token", "") or ""
|
|
||||||
|
|
||||||
if error_id != 0:
|
|
||||||
raise RuntimeError(
|
|
||||||
"扫码登录失败:errorID=" + str(error_id) + ",响应=" + json.dumps(resp, ensure_ascii=False)
|
|
||||||
+ "\n 提示:errorID=57 通常是二维码已失效/已被使用,或请求头带了 Content-Type"
|
|
||||||
)
|
|
||||||
if user_id == -1 or not token:
|
|
||||||
raise RuntimeError(
|
|
||||||
"扫码登录失败:userID=" + str(user_id) + ", token='" + token + "'"
|
|
||||||
)
|
|
||||||
return int(user_id), str(token)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 公开 API
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int, str]:
|
|
||||||
"""
|
|
||||||
解析二维码并换取 userId 与 token。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
qr_input: 84 字符二维码串(SGWCMAID...),或二维码 URL
|
|
||||||
chip_id: 机台 Keychip 短值(可选,默认取 config.CLIENT_ID)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
(user_id, token) 元组,可直接传给 GetUserPreview / UserLogin 等
|
|
||||||
|
|
||||||
异常:
|
|
||||||
ValueError: 二维码格式不合法
|
|
||||||
RuntimeError: 服务器返回错误或网络异常
|
|
||||||
"""
|
|
||||||
log_info("解析二维码登录:chip_id=" + chip_id)
|
|
||||||
|
|
||||||
qr_string = _normalize_qr(qr_input)
|
|
||||||
body_bytes, timestamp, key = _build_body(qr_string, chip_id)
|
|
||||||
|
|
||||||
log_request(
|
|
||||||
url="https://" + AIMEDB_HOST + ":" + str(AIMEDB_PORT) + AIMEDB_PATH,
|
|
||||||
body={
|
|
||||||
"chipID": chip_id,
|
|
||||||
"openGameID": QR_OPEN_GAME_ID,
|
|
||||||
"key": key,
|
|
||||||
"qrCode": qr_string[QR_PREFIX_LEN:],
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"titlekey": QR_TITLE_KEY,
|
|
||||||
},
|
|
||||||
headers={"Connection": "Keep-Alive", "User-Agent": QR_USER_AGENT,
|
|
||||||
"Content-Length": str(len(body_bytes))},
|
|
||||||
api_name="aimedb/get_data",
|
|
||||||
)
|
|
||||||
|
|
||||||
log_info("发送登录请求:timestamp=" + timestamp + " host=" + AIMEDB_HOST + AIMEDB_PATH)
|
|
||||||
resp = _send(body_bytes)
|
|
||||||
user_id, token = _parse(resp)
|
|
||||||
|
|
||||||
log_info("扫码登录成功:user_id=" + str(user_id) + " token=" + token[:8] + "...(长度 "
|
|
||||||
+ str(len(token)) + ")")
|
|
||||||
return user_id, token
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 单独运行(调试)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from .log_setup import setup_logging, teardown_logging
|
|
||||||
|
|
||||||
def _main() -> int:
|
|
||||||
setup_logging("chime_test")
|
|
||||||
try:
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("用法: python -m api.chime <二维码串或URL> [chip_id]")
|
|
||||||
return 1
|
|
||||||
chip = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_CHIP_ID
|
|
||||||
user_id, token = ParseQRCode(sys.argv[1], chip_id=chip)
|
|
||||||
print("")
|
|
||||||
print("user_id =", user_id)
|
|
||||||
print("token =", token)
|
|
||||||
return 0
|
|
||||||
except Exception as exc:
|
|
||||||
log_error("扫码登录失败:" + str(exc))
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
teardown_logging()
|
|
||||||
|
|
||||||
raise SystemExit(_main())
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_game_charge.py —— 取游戏计费配置(GetGameChargeApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取服务器下发的计费档位。
|
|
||||||
|
|
||||||
对应接口:GetGameChargeApi
|
|
||||||
请求体 :{"isAll": false}(GameChargeRequestVO)
|
|
||||||
响应 :{length, gameChargeList:[{orderId,chargeId,price,startDate,endDate}]}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameChargeRequestVO.cs
|
|
||||||
source/2026-10-05.log:9 真机请求 {"isAll":false}
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetGameCharge
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info
|
|
||||||
from .maimai_net import MaimaiNetClient, request_dict
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GameChargeRequestVO:
|
|
||||||
"""请求 VO(对应 GameChargeRequestVO)。"""
|
|
||||||
isAll: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
def GetGameCharge(
|
|
||||||
*,
|
|
||||||
is_all: bool = False,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取游戏计费配置(对应 GetGameChargeApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 gameChargeList
|
|
||||||
"""
|
|
||||||
log_info("取游戏计费配置:isAll=" + str(is_all))
|
|
||||||
return request_dict(
|
|
||||||
"GetGameChargeApi", GameChargeRequestVO(isAll=is_all),
|
|
||||||
user_id=0, client_id=client_id,
|
|
||||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
|
||||||
)
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_game_settings.py —— 获取游戏设置(GetGameSettingApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
功能:取服务器下发的运行时配置(维护窗口、心跳间隔、包超时、各类上限)。
|
|
||||||
|
|
||||||
对应接口:GetGameSettingApi
|
|
||||||
请求体 :{"placeId": <店铺ID>, "clientId": "<机台短值>"}
|
|
||||||
source/Assembly-CSharp/Net.VO.Mai2/GameSettingRequestVO.cs
|
|
||||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:21-24
|
|
||||||
|
|
||||||
★ 真机调用时机:会话最开始、Ping 之前(source/2026-10-05.log:1)。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetGameSettings
|
|
||||||
resp = GetGameSettings()
|
|
||||||
print(resp.gameSetting.isMaintenance, resp.gameSetting.requestInterval)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, PLACE_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info, log_warning
|
|
||||||
from .maimai_net import MaimaiNetClient, request_dict
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# VO(字段对照 source/Assembly-CSharp/Net.VO.Mai2/GameSetting.cs 与真机响应)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GameSetting:
|
|
||||||
"""游戏运行时配置(对应 Net.VO.Mai2.GameSetting)。"""
|
|
||||||
isMaintenance: bool = False
|
|
||||||
requestInterval: int = 0
|
|
||||||
rebootStartTime: str = ""
|
|
||||||
rebootEndTime: str = ""
|
|
||||||
rebootInterval: int = 0
|
|
||||||
movieUploadLimit: int = 0
|
|
||||||
movieStatus: int = 0
|
|
||||||
movieServerUri: str = ""
|
|
||||||
deliverServerUri: str = ""
|
|
||||||
oldServerUri: str = ""
|
|
||||||
usbDlServerUri: str = ""
|
|
||||||
maxCountRivalMusic: int = 0
|
|
||||||
replicationDelayLimit: int = 0
|
|
||||||
exclusionStartTime: str = ""
|
|
||||||
exclusionEndTime: str = ""
|
|
||||||
pingDisable: bool = False
|
|
||||||
packetTimeout: int = 0
|
|
||||||
packetTimeoutLong: int = 0
|
|
||||||
packetRetryCount: int = 0
|
|
||||||
userDataDlErrTimeout: int = 0
|
|
||||||
userDataDlErrRetryCount: int = 0
|
|
||||||
userDataDlErrSamePacketRetryCount: int = 0
|
|
||||||
userDataUpSkipTimeout: int = 0
|
|
||||||
userDataUpSkipRetryCount: int = 0
|
|
||||||
iconPhotoDisable: bool = False
|
|
||||||
uploadPhotoDisable: bool = False
|
|
||||||
photoDisableArea: Optional[str] = None
|
|
||||||
collectDelayInfoDisable: bool = False
|
|
||||||
maxCountMusic: int = 0
|
|
||||||
maxCountItem: int = 0
|
|
||||||
nameEntryDisable: bool = False
|
|
||||||
packetRecreateCount: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GameSettingRequestVO:
|
|
||||||
"""请求 VO(对应 GameSettingRequestVO)。"""
|
|
||||||
placeId: int = 0
|
|
||||||
clientId: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GameSettingResponseVO:
|
|
||||||
"""响应 VO(对应 GameSettingResponseVO)。"""
|
|
||||||
isAouAccession: bool = False
|
|
||||||
gameSetting: GameSetting = field(default_factory=GameSetting)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 公开 API
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def GetGameSettings(
|
|
||||||
*,
|
|
||||||
place_id: int = PLACE_ID,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> GameSettingResponseVO:
|
|
||||||
"""
|
|
||||||
获取游戏设置(服务器下发的运行时配置)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
place_id: 店铺 ID(可选,默认 config.PLACE_ID)
|
|
||||||
client_id: 机台短值(可选,默认 config.CLIENT_ID)
|
|
||||||
timeout_ms: 超时毫秒(可选)
|
|
||||||
client: 可复用的 MaimaiNetClient(可选)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
GameSettingResponseVO
|
|
||||||
"""
|
|
||||||
log_info("获取游戏设置:place_id=" + str(place_id) + " client_id=" + client_id)
|
|
||||||
|
|
||||||
raw = request_dict(
|
|
||||||
"GetGameSettingApi",
|
|
||||||
GameSettingRequestVO(placeId=place_id, clientId=client_id),
|
|
||||||
user_id=0, client_id=client_id,
|
|
||||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
gs_raw = raw.get("gameSetting") or {}
|
|
||||||
known = {f for f in GameSetting.__dataclass_fields__}
|
|
||||||
gs = GameSetting(**{k: v for k, v in gs_raw.items() if k in known})
|
|
||||||
resp = GameSettingResponseVO(
|
|
||||||
isAouAccession=bool(raw.get("isAouAccession", False)),
|
|
||||||
gameSetting=gs,
|
|
||||||
)
|
|
||||||
|
|
||||||
if gs.isMaintenance:
|
|
||||||
log_warning("服务器处于维护状态(isMaintenance=True)")
|
|
||||||
if gs.pingDisable:
|
|
||||||
log_info("服务器已禁用心跳(pingDisable=True)")
|
|
||||||
|
|
||||||
log_info(
|
|
||||||
"游戏设置获取完成:isMaintenance=" + str(gs.isMaintenance)
|
|
||||||
+ " requestInterval=" + str(gs.requestInterval)
|
|
||||||
+ " packetTimeout=" + str(gs.packetTimeout)
|
|
||||||
+ " packetTimeoutLong=" + str(gs.packetTimeoutLong)
|
|
||||||
+ " pingDisable=" + str(gs.pingDisable)
|
|
||||||
)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
def is_maintenance(settings: GameSettingResponseVO) -> bool:
|
|
||||||
"""便捷判断:服务器是否处于维护状态。"""
|
|
||||||
return bool(settings.gameSetting.isMaintenance)
|
|
||||||
|
|
||||||
|
|
||||||
def is_ping_disabled(settings: GameSettingResponseVO) -> bool:
|
|
||||||
"""便捷判断:是否已禁用心跳。"""
|
|
||||||
return bool(settings.gameSetting.pingDisable)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 单独运行(调试)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
from .log_setup import setup_logging, teardown_logging
|
|
||||||
|
|
||||||
def _main() -> int:
|
|
||||||
setup_logging("gamesettings_test")
|
|
||||||
try:
|
|
||||||
resp = GetGameSettings()
|
|
||||||
gs = resp.gameSetting
|
|
||||||
print("")
|
|
||||||
for name in GameSetting.__dataclass_fields__:
|
|
||||||
print(" " + name.ljust(34) + "= " + str(getattr(gs, name)))
|
|
||||||
return 0
|
|
||||||
except Exception as exc:
|
|
||||||
from .log_setup import log_error
|
|
||||||
log_error("获取游戏设置失败:" + str(exc))
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
teardown_logging()
|
|
||||||
|
|
||||||
raise SystemExit(_main())
|
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_need_user_data.py —— 结算快照编排(统一调用各取数脚本)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
功能:一键抓取 UpsertUserAll 需要的全部基线数据,整理成「节点字典」。
|
|
||||||
|
|
||||||
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(get_user_data.py / get_user_map.py …)。
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
为什么需要快照
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
服务器对 userData 是**整表覆盖**语义,对差分节点是**只回传变化项**语义。
|
|
||||||
要把本局的变化正确表达出来,必须先有一份「登录时的服务器状态」作基线,
|
|
||||||
再把本局的影响叠加到基线上 —— 这就是快照的作用。
|
|
||||||
|
|
||||||
真机对照(source/2026-10-05.log):
|
|
||||||
|
|
||||||
| 节点 | Get 侧(基线) | Upsert 侧(本局后) |
|
|
||||||
|---------------------|-------------------|----------------------|
|
|
||||||
| userData.playCount | 1257 | 1258 (+1) |
|
|
||||||
| userMapList | 104 条 | 仅 1 条(distance 变)|
|
|
||||||
| userMusicDetailList | 739 组 | 仅 1 条(playCount+1)|
|
|
||||||
| userItemList | 1070 条 | [](无变化) |
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
调用示例
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
from api import GetNeedUserData
|
|
||||||
|
|
||||||
snapshot = GetNeedUserData(user_id, token=token)
|
|
||||||
# 之后直接传给 UpsertUserAll(snapshot=snapshot, ...)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional, Sequence
|
|
||||||
|
|
||||||
from config import CLIENT_ID, PLACE_ID, PLAY_SHOP_ITEM_IDS, REGION_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_debug, log_info, log_warning
|
|
||||||
from .maimai_net 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
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 落盘目录:<工程根>/data/<userId>.json
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
#: 工程根目录(本文件在 <root>/api/ 下)
|
|
||||||
PROJECT_ROOT: Path = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
#: 用户数据目录:<工程根>/data
|
|
||||||
DATA_DIR: Path = PROJECT_ROOT / "data"
|
|
||||||
|
|
||||||
|
|
||||||
def user_data_path(user_id: int) -> Path:
|
|
||||||
"""用户数据的落盘路径:<工程根>/data/<userId>.json"""
|
|
||||||
return DATA_DIR / (str(int(user_id)) + ".json")
|
|
||||||
|
|
||||||
|
|
||||||
def save_user_data(snapshot: Dict[str, Any], user_id: int,
|
|
||||||
path: Optional[str] = None) -> str:
|
|
||||||
"""
|
|
||||||
把 GetNeedUserData 抓到的数据写到 <工程根>/data/<userId>.json。
|
|
||||||
|
|
||||||
★ **新数据总是覆盖老数据**(不是追加、也不是合并)。
|
|
||||||
★ 目录不存在会自动创建。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
snapshot: GetNeedUserData 的返回值
|
|
||||||
user_id: 用户 ID(决定文件名)
|
|
||||||
path: 自定义路径(可选,默认走 data/<userId>.json)
|
|
||||||
|
|
||||||
返回:实际写入的文件路径
|
|
||||||
"""
|
|
||||||
target = Path(path) if path else user_data_path(user_id)
|
|
||||||
try:
|
|
||||||
os.makedirs(str(target.parent), exist_ok=True)
|
|
||||||
with open(str(target), "w", encoding="utf-8") as fp:
|
|
||||||
json.dump(snapshot, fp, ensure_ascii=False, indent=2, default=str)
|
|
||||||
except Exception as exc:
|
|
||||||
log_warning("用户数据落盘失败(不影响本次运行):" + str(exc))
|
|
||||||
return str(target)
|
|
||||||
size = 0
|
|
||||||
try:
|
|
||||||
size = os.path.getsize(str(target))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
log_info("用户数据已保存(覆盖):" + str(target) + " " + str(size) + " 字节")
|
|
||||||
return str(target)
|
|
||||||
|
|
||||||
|
|
||||||
def load_user_data(user_id: int, path: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
读回 <工程根>/data/<userId>.json。
|
|
||||||
|
|
||||||
找不到文件时抛 FileNotFoundError(调用方自行决定要不要回退到联网抓取)。
|
|
||||||
"""
|
|
||||||
target = Path(path) if path else user_data_path(user_id)
|
|
||||||
if not target.exists():
|
|
||||||
raise FileNotFoundError("用户数据文件不存在:" + str(target))
|
|
||||||
with open(str(target), "r", encoding="utf-8") as fp:
|
|
||||||
data = json.load(fp)
|
|
||||||
log_info("用户数据已加载:" + str(target))
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def list_saved_user_ids() -> List[int]:
|
|
||||||
"""列出 data/ 下已保存的全部 userId(按文件名解析,跳过解析失败的)。"""
|
|
||||||
if not DATA_DIR.exists():
|
|
||||||
return []
|
|
||||||
out: List[int] = []
|
|
||||||
for name in os.listdir(str(DATA_DIR)):
|
|
||||||
if not name.endswith(".json"):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
out.append(int(name[:-5]))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return sorted(out)
|
|
||||||
|
|
||||||
|
|
||||||
#: 默认要抓的节点(顺序即抓取顺序)
|
|
||||||
#: ★ 只列 UpsertUserAll 真正需要的;纯展示类接口(排行/活动/大会…)不在此列。
|
|
||||||
DEFAULT_SNAPSHOT_NODES: tuple = (
|
|
||||||
"userData",
|
|
||||||
"userExtend",
|
|
||||||
"userOption",
|
|
||||||
"userCharacterList",
|
|
||||||
"userMapList",
|
|
||||||
"userLoginBonusList",
|
|
||||||
"userRatingList",
|
|
||||||
"userItemList",
|
|
||||||
"userMusicDetailList",
|
|
||||||
"userCourseList",
|
|
||||||
"userChargeList",
|
|
||||||
"userFavoriteList",
|
|
||||||
"userActivityList",
|
|
||||||
"userMissionDataList",
|
|
||||||
"userIntimateList",
|
|
||||||
"userShopItemStockList",
|
|
||||||
"userFavoritemusicList",
|
|
||||||
"userKaleidxScopeList",
|
|
||||||
"userGhost",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def GetNeedUserData(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
token: str = "",
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
place_id: int = PLACE_ID,
|
|
||||||
region_id: int = REGION_ID,
|
|
||||||
shop_item_ids: Optional[Sequence[int]] = None,
|
|
||||||
nodes: Optional[Sequence[str]] = None,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
strict: bool = False,
|
|
||||||
save: bool = True,
|
|
||||||
save_path: Optional[str] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
抓取结算所需的全部基线数据。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
user_id: 用户 ID(必填)
|
|
||||||
token: 会话令牌(可选,多数取数接口不需要)
|
|
||||||
client_id: 机台短值
|
|
||||||
place_id: 店铺 ID
|
|
||||||
region_id: 区域 ID
|
|
||||||
shop_item_ids: 商店商品清单(可选,默认 config.PLAY_SHOP_ITEM_IDS)
|
|
||||||
nodes: 要抓的节点(可选,默认 DEFAULT_SNAPSHOT_NODES)
|
|
||||||
timeout_ms: 超时毫秒
|
|
||||||
client: 可复用的 MaimaiNetClient
|
|
||||||
strict: True = 任一接口失败就抛异常;
|
|
||||||
False(默认)= 记录失败并跳过,保证链路不崩
|
|
||||||
save: True(默认)= 抓完自动落盘到 <工程根>/data/<userId>.json
|
|
||||||
(★ 新数据总是覆盖老数据)
|
|
||||||
save_path: 自定义落盘路径(可选,给了就忽略 save 的默认路径规则)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
{
|
|
||||||
"userData": <GetUserDataApi 原始响应>,
|
|
||||||
"userExtend": <GetUserExtendApi 原始响应>,
|
|
||||||
"userMapList": {"userMapList": [...]}, # 已摊平/翻页
|
|
||||||
"userMusicDetailList": {"userMusicList": [...]},# 已摊平成单条
|
|
||||||
...
|
|
||||||
"_failed": [ {"node":.., "error":..}, ... ]
|
|
||||||
}
|
|
||||||
这个结构可以直接传给 UpsertUserAll(snapshot=...),也可 json 落盘。
|
|
||||||
"""
|
|
||||||
wanted = list(nodes) if nodes else list(DEFAULT_SNAPSHOT_NODES)
|
|
||||||
log_info("抓取结算快照:user_id=" + str(user_id) + " 节点数=" + str(len(wanted)))
|
|
||||||
|
|
||||||
raw: Dict[str, Any] = {}
|
|
||||||
failed: List[Dict[str, str]] = []
|
|
||||||
|
|
||||||
def _call(node: str, fn, *args, **kwargs):
|
|
||||||
"""逐节点容错:单个接口失败不影响整体。"""
|
|
||||||
try:
|
|
||||||
raw[node] = fn(*args, **kwargs)
|
|
||||||
return True
|
|
||||||
except Exception as exc:
|
|
||||||
failed.append({"node": node, "error": type(exc).__name__ + ": " + str(exc)})
|
|
||||||
log_warning("节点 " + node + " 抓取失败:" + type(exc).__name__ + ": " + str(exc))
|
|
||||||
if strict:
|
|
||||||
raise
|
|
||||||
return False
|
|
||||||
|
|
||||||
common = dict(client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
|
|
||||||
# ---- 逐个抓取(顺序与 DEFAULT_SNAPSHOT_NODES 一致)----
|
|
||||||
if "userData" in wanted:
|
|
||||||
_call("userData", GetUserData, user_id, **common)
|
|
||||||
|
|
||||||
if "userExtend" in wanted:
|
|
||||||
_call("userExtend", GetUserExtend, user_id, **common)
|
|
||||||
|
|
||||||
if "userOption" in wanted:
|
|
||||||
_call("userOption", GetUserOption, user_id, **common)
|
|
||||||
|
|
||||||
if "userCharacterList" in wanted:
|
|
||||||
_call("userCharacterList", GetUserCharacter, user_id, **common)
|
|
||||||
|
|
||||||
if "userMapList" in wanted:
|
|
||||||
_call("userMapList", GetUserMapAll, user_id, **common)
|
|
||||||
|
|
||||||
if "userLoginBonusList" in wanted:
|
|
||||||
_call("userLoginBonusList", GetUserLoginBonusAll, user_id, **common)
|
|
||||||
|
|
||||||
if "userRatingList" in wanted:
|
|
||||||
_call("userRatingList", GetUserRating, user_id, **common)
|
|
||||||
|
|
||||||
if "userItemList" in wanted:
|
|
||||||
_call("userItemList", GetUserItemAll, user_id, **common)
|
|
||||||
|
|
||||||
if "userMusicDetailList" in wanted:
|
|
||||||
_call("userMusicDetailList", GetUserMusicDetailAll, user_id, **common)
|
|
||||||
|
|
||||||
if "userCourseList" in wanted:
|
|
||||||
_call("userCourseList", GetUserCourseAll, user_id, **common)
|
|
||||||
|
|
||||||
if "userChargeList" in wanted:
|
|
||||||
_call("userChargeList", GetUserCharge, user_id, **common)
|
|
||||||
|
|
||||||
if "userFavoriteList" in wanted:
|
|
||||||
# ★ 该接口额外带一份**原始** itemKind 副本(_raw),
|
|
||||||
# 供 UpsertUserAll 生成 isNewFavoriteList 位串用
|
|
||||||
# —— 真机 itemKind=10 回的是 null,正是它产生了位串里的 "1"。
|
|
||||||
if _call("userFavoriteList", GetUserFavoriteAll, user_id, **common):
|
|
||||||
fav_raw = (raw.get("userFavoriteList") or {}).get("_raw")
|
|
||||||
if fav_raw:
|
|
||||||
raw["_raw_favorite"] = fav_raw
|
|
||||||
|
|
||||||
if "userActivityList" in wanted:
|
|
||||||
_call("userActivityList", GetUserActivity, user_id, **common)
|
|
||||||
|
|
||||||
if "userMissionDataList" in wanted:
|
|
||||||
# ★ 该接口的响应里同时带着 userWeeklyData(周界),
|
|
||||||
# 它是 Upsert 侧 userWeeklyData 节点的来源,会被自动提取。
|
|
||||||
_call("userMissionDataList", GetUserMissionData, user_id, **common)
|
|
||||||
|
|
||||||
if "userIntimateList" in wanted:
|
|
||||||
_call("userIntimateList", GetUserIntimate, user_id, **common)
|
|
||||||
|
|
||||||
if "userShopItemStockList" in wanted:
|
|
||||||
ids = list(shop_item_ids) if shop_item_ids is not None else list(PLAY_SHOP_ITEM_IDS)
|
|
||||||
if ids:
|
|
||||||
_call("userShopItemStockList", GetUserShopStock, user_id,
|
|
||||||
shop_item_id_list=ids, **common)
|
|
||||||
else:
|
|
||||||
log_debug("未配置 PLAY_SHOP_ITEM_IDS,跳过商店库存节点")
|
|
||||||
|
|
||||||
if "userFavoritemusicList" in wanted:
|
|
||||||
_call("userFavoritemusicList", GetUserFavoriteItemAll, user_id, 1, **common)
|
|
||||||
|
|
||||||
if "userKaleidxScopeList" in wanted:
|
|
||||||
_call("userKaleidxScopeList", GetUserKaleidxScope, user_id, **common)
|
|
||||||
|
|
||||||
if "userGhost" in wanted:
|
|
||||||
_call("userGhost", GetUserGhost, user_id, **common)
|
|
||||||
|
|
||||||
raw["_failed"] = failed
|
|
||||||
raw["_userId"] = int(user_id)
|
|
||||||
log_info("结算快照抓取完成:成功 " + str(len(raw) - 2) + " 个节点,失败 "
|
|
||||||
+ str(len(failed)) + " 个")
|
|
||||||
|
|
||||||
# ★ 落盘:<工程根>/data/<userId>.json(覆盖式)
|
|
||||||
if save or save_path:
|
|
||||||
try:
|
|
||||||
save_user_data(raw, user_id, path=save_path)
|
|
||||||
except Exception as exc:
|
|
||||||
log_warning("用户数据落盘失败(不影响本次运行):" + str(exc))
|
|
||||||
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def save_snapshot(snapshot: Dict[str, Any], path: str) -> str:
|
|
||||||
"""把快照写到 JSON 文件(便于离线复用 / 人工核对)。"""
|
|
||||||
with open(path, "w", encoding="utf-8") as fp:
|
|
||||||
json.dump(snapshot, fp, ensure_ascii=False, indent=2, default=str)
|
|
||||||
log_info("快照已保存:" + path)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def load_snapshot(path: str) -> Dict[str, Any]:
|
|
||||||
"""从 JSON 文件读回快照。"""
|
|
||||||
with open(path, "r", encoding="utf-8") as fp:
|
|
||||||
data = json.load(fp)
|
|
||||||
log_info("快照已加载:" + path)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_snapshot(snapshot: Dict[str, Any]) -> Dict[str, int]:
|
|
||||||
"""
|
|
||||||
统计快照各节点的条数,便于快速核对(返回 {节点名: 条数})。
|
|
||||||
|
|
||||||
条数含义:
|
|
||||||
userData 这类单元素数组 -> 1
|
|
||||||
userMusicDetailList 这类列表 -> 摊平后的条数
|
|
||||||
"""
|
|
||||||
out: Dict[str, int] = {}
|
|
||||||
for node, value in (snapshot or {}).items():
|
|
||||||
if node.startswith("_"):
|
|
||||||
continue
|
|
||||||
if isinstance(value, list):
|
|
||||||
out[node] = len(value)
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
# 从响应里挑出列表
|
|
||||||
found = False
|
|
||||||
for key, sub in value.items():
|
|
||||||
if isinstance(sub, list):
|
|
||||||
out[node] = len(sub)
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
if not found:
|
|
||||||
out[node] = 1
|
|
||||||
else:
|
|
||||||
out[node] = 0
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 单独运行(调试)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from .log_setup import log_error, setup_logging, teardown_logging
|
|
||||||
|
|
||||||
def _main() -> int:
|
|
||||||
setup_logging("get_need_user_data_test")
|
|
||||||
try:
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("用法: python -m api.get_need_user_data <user_id> [--token TOKEN] [--save 路径]")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
user_id = int(sys.argv[1])
|
|
||||||
token = ""
|
|
||||||
save_path = ""
|
|
||||||
args = sys.argv[2:]
|
|
||||||
i = 0
|
|
||||||
while i < len(args):
|
|
||||||
if args[i] == "--token" and i + 1 < len(args):
|
|
||||||
token = args[i + 1]
|
|
||||||
i += 2
|
|
||||||
elif args[i] == "--save" and i + 1 < len(args):
|
|
||||||
save_path = args[i + 1]
|
|
||||||
i += 2
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
snap = GetNeedUserData(user_id, token=token)
|
|
||||||
summary = summarize_snapshot(snap)
|
|
||||||
print("")
|
|
||||||
for node, count in summary.items():
|
|
||||||
print(" " + node.ljust(28) + "= " + str(count) + " 条")
|
|
||||||
if save_path:
|
|
||||||
save_snapshot(snap, save_path)
|
|
||||||
return 0
|
|
||||||
except Exception as exc:
|
|
||||||
log_error("抓取快照失败:" + str(exc))
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
teardown_logging()
|
|
||||||
|
|
||||||
raise SystemExit(_main())
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_activity.py —— 取用户活动记录(GetUserActivityApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家的活动/成就记录(playList 与 musicList 两个列表)。
|
|
||||||
|
|
||||||
对应接口:GetUserActivityApi
|
|
||||||
请求体 :UserIdRequestVO
|
|
||||||
响应 :{userActivity:{playList:[...], musicList:[...]}}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserActivityRequestVO.cs
|
|
||||||
|
|
||||||
★ 形状特例(36 个 Upsert 节点里唯一一个 Get 与 Upsert 形状不同的):
|
|
||||||
Get 侧 :单个对象 userActivity = {playList, musicList}
|
|
||||||
Upsert 侧:**单元素数组** userActivityList = [ {playList, musicList} ]
|
|
||||||
不是把两个数组首尾相接摊平。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserActivity
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 GetUserActivity(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户活动记录(对应 GetUserActivityApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userActivity 子对象
|
|
||||||
"""
|
|
||||||
log_info("取用户活动记录:user_id=" + str(user_id))
|
|
||||||
return _get("GetUserActivityApi", UserIdRequestVO(userId=user_id), user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_character.py —— 取用户角色列表(GetUserCharacterApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家所有角色的等级与觉醒度(真机 312 条)。
|
|
||||||
|
|
||||||
对应接口:GetUserCharacterApi
|
|
||||||
请求体 :UserIdRequestVO
|
|
||||||
响应 :{userId, length, userCharacterList:[...]}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserCharacterRequestVO.cs
|
|
||||||
|
|
||||||
★ 回传 Upsert 时只保留 characterId / level / awakening / useCount 四个字段
|
|
||||||
(真机 source/2026-10-05.log:123 的 userCharacterList 就是这 4 个键)。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserCharacter
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 GetUserCharacter(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户角色列表(对应 GetUserCharacterApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userCharacterList
|
|
||||||
"""
|
|
||||||
log_info("取用户角色列表:user_id=" + str(user_id))
|
|
||||||
return _get("GetUserCharacterApi", UserIdRequestVO(userId=user_id), user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_course.py —— 取用户课题模式成绩(GetUserCourseApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家的 COURSE(课题)模式通关记录(真机 16 条)。
|
|
||||||
|
|
||||||
对应接口:GetUserCourseApi
|
|
||||||
请求体 :UserCourseRequestVO(userId + nextIndex,**没有 maxCount**)
|
|
||||||
响应 :{userId, length, nextIndex, userCourseList:[...]}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserCourseRequestVO.cs
|
|
||||||
source/2026-10-05.log:55 请求体就是 {"userId":..,"nextIndex":0}
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserCourse
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info
|
|
||||||
from .maimai_net import MaimaiNetClient
|
|
||||||
from .user_getter_common import _get, UserCourseRequestVO, get_all_pages
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserCourse(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
next_index: int = 0,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户课题模式成绩(单页,对应 GetUserCourseApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userCourseList
|
|
||||||
"""
|
|
||||||
log_info("取用户课题成绩:user_id=" + str(user_id) + " next_index=" + str(next_index))
|
|
||||||
return _get(
|
|
||||||
"GetUserCourseApi",
|
|
||||||
UserCourseRequestVO(userId=user_id, nextIndex=next_index),
|
|
||||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserCourseAll(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户课题成绩(自动翻页取全)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
{"userCourseList": [...]}
|
|
||||||
"""
|
|
||||||
log_info("取用户课题成绩(全量):user_id=" + str(user_id))
|
|
||||||
items = get_all_pages(
|
|
||||||
"GetUserCourseApi", user_id, "userCourseList",
|
|
||||||
body_factory=lambda ni: UserCourseRequestVO(userId=user_id, nextIndex=ni or 0),
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
log_info("课题成绩获取完成:" + str(len(items)) + " 条")
|
|
||||||
return {"userCourseList": items}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_data.py —— 取用户全量数据(GetUserDataApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家档案(86 个字段的大对象),是 upsertUserAll.userData 的**唯一正确来源**。
|
|
||||||
|
|
||||||
对应接口:GetUserDataApi
|
|
||||||
请求体 :UserIdRequestVO(只有 userId)
|
|
||||||
响应 :{userId, userData:{86 键}, banState}
|
|
||||||
依据 :source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserData.cs:17
|
|
||||||
source/Assembly-CSharp/Net.VO.Mai2/UserDetailResponseVO.cs
|
|
||||||
|
|
||||||
★ banState 在 Get 侧是**顶层**字段(UserDetailResponseVO.cs:15),
|
|
||||||
回传 Upsert 时要搬进 userData 内部(真机 source/2026-10-05.log:123 的 userData 里有 banState)。
|
|
||||||
|
|
||||||
⚠ 常见误区:有资料认为 userData 由 GetGameKaleidxScopeApi 下发。
|
|
||||||
但 GameKaleidxScopeResponseVO.cs 里只有 gameKaleidxScopeList 字段,根本没有 userData。
|
|
||||||
真机日志里那一次「GetGameKaleidxScope 的响应带 userData」,是抓包工具按时间相邻
|
|
||||||
记录造成的假象(两条响应被合并显示)。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserData
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info
|
|
||||||
from .maimai_net import MaimaiNetClient
|
|
||||||
from .user_getter_common import _get, UserIdRequestVO
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserData(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户全量数据(对应 GetUserDataApi)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
user_id: 用户 ID(必填)
|
|
||||||
client_id: 机台短值(可选)
|
|
||||||
timeout_ms: 超时毫秒(可选)
|
|
||||||
client: 可复用的 MaimaiNetClient(可选)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict:{"userId":.., "userData":{..}, "banState":..}
|
|
||||||
"""
|
|
||||||
log_info("取用户全量数据:user_id=" + str(user_id))
|
|
||||||
return _get("GetUserDataApi", UserIdRequestVO(userId=user_id), user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_extend.py —— 取用户扩展设置(GetUserExtendApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家上次的选曲 / 分类 / 卡片槽等扩展状态。
|
|
||||||
|
|
||||||
对应接口:GetUserExtendApi
|
|
||||||
请求体 :UserIdRequestVO
|
|
||||||
响应 :{userId, userExtend:{...}}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserExtendRequestVO.cs
|
|
||||||
|
|
||||||
★ 其中 categoryIndex(曲种索引)与 userPlaylog 的 extNum4 必须一致
|
|
||||||
(真机两处都是 2022),组包时要注意。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserExtend
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info
|
|
||||||
from .maimai_net import MaimaiNetClient
|
|
||||||
from .user_getter_common import _get, UserIdRequestVO
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserExtend(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户扩展设置(对应 GetUserExtendApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userExtend 子对象
|
|
||||||
"""
|
|
||||||
log_info("取用户扩展设置:user_id=" + str(user_id))
|
|
||||||
return _get("GetUserExtendApi", UserIdRequestVO(userId=user_id), user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_favorite.py —— 取用户收藏(GetUserFavoriteApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家各类收藏夹(称号 / 头像 / 相框 / 搭档 / 背景…)。
|
|
||||||
|
|
||||||
对应接口:GetUserFavoriteApi
|
|
||||||
请求体 :UserFavoriteRequestVO(userId + itemKind)
|
|
||||||
响应 :{userId, userFavorite:{itemKind, itemIdList:[...]}}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFavoriteRequestVO.cs
|
|
||||||
source/2026-10-05.log:59-68 真机依次请求 itemKind = 3,1,2,10,11
|
|
||||||
|
|
||||||
★ 回传 Upsert 时 userFavoriteList 是**恒 5 条**的完整数组(不做剔除),
|
|
||||||
位串 isNewFavoriteList 是三值 "0"/"1"/"2"。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserFavorite, GetUserFavoriteAll
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info, log_warning
|
|
||||||
from .maimai_net import MaimaiNetClient
|
|
||||||
from .user_getter_common import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserFavorite(
|
|
||||||
user_id: int,
|
|
||||||
item_kind: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户某一类收藏(对应 GetUserFavoriteApi)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
user_id: 用户 ID(必填)
|
|
||||||
item_kind: 收藏类别(必填):3=头像 1=称号 2=名牌 10=搭档 11=相框
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userFavorite
|
|
||||||
"""
|
|
||||||
log_info("取用户收藏:user_id=" + str(user_id) + " itemKind=" + str(item_kind))
|
|
||||||
return _get(
|
|
||||||
"GetUserFavoriteApi",
|
|
||||||
UserFavoriteRequestVO(userId=user_id, itemKind=item_kind),
|
|
||||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserFavoriteAll(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
item_kinds: Optional[List[int]] = None,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户全部收藏(按真机顺序请求 5 个类别)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
item_kinds: 类别列表(可选,默认 FAVORITE_ITEM_KINDS = (3,1,2,10,11))
|
|
||||||
|
|
||||||
返回:
|
|
||||||
{"userFavoriteList": [{itemKind, itemIdList}, ...]},恒 5 条
|
|
||||||
"""
|
|
||||||
kinds = list(item_kinds) if item_kinds else list(FAVORITE_ITEM_KINDS)
|
|
||||||
log_info("取用户全部收藏:user_id=" + str(user_id) + " kinds=" + str(kinds))
|
|
||||||
|
|
||||||
result: List[Dict[str, Any]] = []
|
|
||||||
raw_items: List[Dict[str, Any]] = []
|
|
||||||
for kind in kinds:
|
|
||||||
resp = _get(
|
|
||||||
"GetUserFavoriteApi",
|
|
||||||
UserFavoriteRequestVO(userId=user_id, itemKind=kind),
|
|
||||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
fav = resp.get("userFavorite") or {}
|
|
||||||
if not fav:
|
|
||||||
log_warning("itemKind=" + str(kind) + " 未返回 userFavorite,按空处理")
|
|
||||||
fav = {"itemKind": kind, "itemIdList": []}
|
|
||||||
|
|
||||||
# ★ 真机里 itemKind=10 的响应回的是 {"itemKind":null,"itemIdList":[]}(服务端怪癖)。
|
|
||||||
# 保留一份**原始**副本供 Upsert 差分用 —— 位串里的 "1" 正是它产生的。
|
|
||||||
raw_items.append(dict(fav))
|
|
||||||
|
|
||||||
# 回传用的副本按请求值补齐 itemKind,避免报文里出现 null
|
|
||||||
filled = dict(fav)
|
|
||||||
if not filled.get("itemKind"):
|
|
||||||
filled["itemKind"] = kind
|
|
||||||
result.append(filled)
|
|
||||||
|
|
||||||
log_info("收藏获取完成:" + str(len(result)) + " 类")
|
|
||||||
return {"userFavoriteList": result, "_raw": raw_items}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_favorite_item.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家收藏的乐曲(kind=1)或收藏的对手(kind=2)。
|
|
||||||
|
|
||||||
对应接口:GetUserFavoriteItemApi
|
|
||||||
请求体 :UserFavoriteItemRequestVO(userId + kind + nextIndex + maxCount=100
|
|
||||||
+ isAllFavoriteItem)
|
|
||||||
响应 :{userId, kind, length, nextIndex, userFavoriteItemList:[{orderId,id}]}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFavoriteItemRequestVO.cs
|
|
||||||
source/2026-10-05.log:93,107 真机 kind=2 与 kind=1 各一次
|
|
||||||
|
|
||||||
★ 字段名是 kind(不是 itemKind),别写错。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserFavoriteItem, GetUserFavoriteItemAll
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info
|
|
||||||
from .maimai_net import MaimaiNetClient
|
|
||||||
from .user_getter_common import _get, UserFavoriteItemRequestVO, get_all_pages
|
|
||||||
|
|
||||||
#: kind 取值
|
|
||||||
FAVORITE_ITEM_KIND_MUSIC = 1 # 收藏乐曲
|
|
||||||
FAVORITE_ITEM_KIND_RIVAL = 2 # 收藏对手
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserFavoriteItem(
|
|
||||||
user_id: int,
|
|
||||||
kind: int = FAVORITE_ITEM_KIND_MUSIC,
|
|
||||||
*,
|
|
||||||
next_index: int = 0,
|
|
||||||
is_all_favorite_item: bool = False,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户收藏项(单页,对应 GetUserFavoriteItemApi)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
user_id: 用户 ID(必填)
|
|
||||||
kind: 1=收藏乐曲 2=收藏对手(默认 1)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userFavoriteItemList
|
|
||||||
"""
|
|
||||||
log_info("取用户收藏项:user_id=" + str(user_id) + " kind=" + str(kind)
|
|
||||||
+ " next_index=" + str(next_index))
|
|
||||||
return _get(
|
|
||||||
"GetUserFavoriteItemApi",
|
|
||||||
UserFavoriteItemRequestVO(
|
|
||||||
userId=user_id, kind=kind, nextIndex=next_index,
|
|
||||||
maxCount=100, isAllFavoriteItem=is_all_favorite_item,
|
|
||||||
),
|
|
||||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserFavoriteItemAll(
|
|
||||||
user_id: int,
|
|
||||||
kind: int = FAVORITE_ITEM_KIND_MUSIC,
|
|
||||||
*,
|
|
||||||
is_all_favorite_item: bool = False,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户收藏项(自动翻页取全)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
{"userFavoriteItemList": [...]}
|
|
||||||
"""
|
|
||||||
log_info("取用户收藏项(全量):user_id=" + str(user_id) + " kind=" + str(kind))
|
|
||||||
items = get_all_pages(
|
|
||||||
"GetUserFavoriteItemApi", user_id, "userFavoriteItemList",
|
|
||||||
body_factory=lambda ni: UserFavoriteItemRequestVO(
|
|
||||||
userId=user_id, kind=kind, nextIndex=ni or 0,
|
|
||||||
maxCount=100, isAllFavoriteItem=is_all_favorite_item),
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
log_info("收藏项获取完成:" + str(len(items)) + " 条")
|
|
||||||
return {"userFavoriteItemList": items}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_friend_bonus.py —— 取用户好友里程(GetUserFriendBonusApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家本次可领取的好友里程(getMiles)。
|
|
||||||
|
|
||||||
对应接口:GetUserFriendBonusApi
|
|
||||||
请求体 :UserIdRequestVO
|
|
||||||
响应 :{returnCode, userId, getMiles}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFriendBonusRequestVO.cs
|
|
||||||
source/2026-10-05.log:111 真机响应 {"returnCode":1,"userId":..,"getMiles":90}
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserFriendBonus, extract_gift_points
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info, log_warning
|
|
||||||
from .maimai_net import MaimaiNetClient
|
|
||||||
from .user_getter_common import _get, UserIdRequestVO
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserFriendBonus(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户好友里程(对应 GetUserFriendBonusApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict:{"returnCode":1, "userId":.., "getMiles":..}
|
|
||||||
"""
|
|
||||||
log_info("取用户好友里程:user_id=" + str(user_id))
|
|
||||||
resp = _get("GetUserFriendBonusApi", UserIdRequestVO(userId=user_id), user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
|
|
||||||
if int(resp.get("returnCode", 0) or 0) != 1:
|
|
||||||
log_warning("好友里程接口返回 returnCode=" + str(resp.get("returnCode")))
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
def extract_gift_points(friend_bonus: Dict[str, Any]) -> int:
|
|
||||||
"""
|
|
||||||
从好友里程响应里取出 getMiles(本次可领取的里程数)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
friend_bonus: GetUserFriendBonus 的返回值
|
|
||||||
|
|
||||||
返回:
|
|
||||||
getMiles 整数(取不到则为 0)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return int((friend_bonus or {}).get("getMiles", 0) or 0)
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_ghost.py —— 取用户幽灵数据(GetUserGhostApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家的「幽灵」对战记录(真机 15 条)。
|
|
||||||
|
|
||||||
对应接口:GetUserGhostApi
|
|
||||||
请求体 :UserIdRequestVO
|
|
||||||
响应 :{length, userGhostList:[...]}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserGhostRequestVO.cs
|
|
||||||
|
|
||||||
★ Upsert 侧 userGhost 默认留空:源码 VOExtensions.cs:235 是
|
|
||||||
dst.userGhost = (!src.MyGhost.NeedSave) ? null : new GetUserGhost[1] {...};
|
|
||||||
只有「本局产生了幽灵」才回传,纯网络脚本复刻不出幽灵数据。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserGhost
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 GetUserGhost(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户幽灵数据(对应 GetUserGhostApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userGhostList
|
|
||||||
"""
|
|
||||||
log_info("取用户幽灵数据:user_id=" + str(user_id))
|
|
||||||
return _get("GetUserGhostApi", UserIdRequestVO(userId=user_id), user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_intimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家与各搭档的亲密度(真机 2 条)。
|
|
||||||
|
|
||||||
对应接口:GetUserIntimateApi
|
|
||||||
请求体 :UserIdRequestVO
|
|
||||||
响应 :{userId, length, userIntimateList:[{partnerId,intimateLevel,intimateCountRewarded}]}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserIntimateRequestVO.cs
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserIntimate
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info
|
|
||||||
from .maimai_net import MaimaiNetClient
|
|
||||||
from .user_getter_common import _get, UserIdRequestVO
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserIntimate(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户搭档亲密度(对应 GetUserIntimateApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userIntimateList
|
|
||||||
"""
|
|
||||||
log_info("取用户搭档亲密度:user_id=" + str(user_id))
|
|
||||||
return _get("GetUserIntimateApi", UserIdRequestVO(userId=user_id), user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_item.py —— 取用户道具(GetUserItemApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家各类道具的库存(真机按 kind 分 12 次请求)。
|
|
||||||
|
|
||||||
对应接口:GetUserItemApi
|
|
||||||
请求体 :UserItemRequestVO(userId + nextIndex + maxCount=100)
|
|
||||||
响应 :{userId, length, nextIndex, itemKind, userItemList:[{itemKind,itemId,stock,isValid}]}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserItemRequestVO.cs
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
★★ 首发 nextIndex 必须带哨兵值(最容易踩的坑)
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
|
|
||||||
|
|
||||||
public PacketGetUserItem(ulong userId, long itemId, ...)
|
|
||||||
{
|
|
||||||
Create(GetNetQuery(userId, itemId * 10000000000L)); // <- 首包哨兵
|
|
||||||
}
|
|
||||||
// Proc(): nextIndex != 0 时继续翻页
|
|
||||||
|
|
||||||
真机日志 source/2026-10-05.log:32 也是 nextIndex=10000000000。
|
|
||||||
传 0 会被服务端拒绝(HTTP 500)。
|
|
||||||
|
|
||||||
★ 请求体里**没有 itemKind 字段** —— 类别是靠 nextIndex 的哨兵值表达的:
|
|
||||||
第 1 页 nextIndex = kind * 1e10
|
|
||||||
后续翻页沿用响应回的 nextIndex
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserItem, GetUserItemAll
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_debug, log_info, log_warning
|
|
||||||
from .maimai_net import MaimaiNetClient
|
|
||||||
from .user_getter_common import _get, UserItemRequestVO, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserItem(
|
|
||||||
user_id: int,
|
|
||||||
item_kind: int,
|
|
||||||
*,
|
|
||||||
next_index: Optional[int] = None,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户某一类道具(对应 GetUserItemApi)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
user_id: 用户 ID(必填)
|
|
||||||
item_kind: 道具类别(必填):1=名牌 2=称号 3=头像 4=礼物 5=乐曲 …
|
|
||||||
next_index: 分页索引;**None(默认)表示首页**,会自动换算成
|
|
||||||
item_kind * 10000000000 的哨兵值。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userItemList
|
|
||||||
"""
|
|
||||||
if next_index is None:
|
|
||||||
next_index = item_kind * ITEM_NEXT_INDEX_FACTOR
|
|
||||||
|
|
||||||
log_info("取用户道具:user_id=" + str(user_id) + " item_kind=" + str(item_kind)
|
|
||||||
+ " nextIndex=" + str(next_index))
|
|
||||||
|
|
||||||
return _get(
|
|
||||||
"GetUserItemApi",
|
|
||||||
UserItemRequestVO(userId=user_id, nextIndex=next_index, maxCount=100),
|
|
||||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserItemAll(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
item_kinds: Optional[List[int]] = None,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户全部道具(按 ITEM_KINDS 逐个类别抓取,并自动翻页)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
item_kinds: 类别列表(可选,默认 ITEM_KINDS)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
{"userItemList": [...]},已合并所有类别与分页
|
|
||||||
"""
|
|
||||||
kinds = list(item_kinds) if item_kinds else list(ITEM_KINDS)
|
|
||||||
log_info("取用户全部道具:user_id=" + str(user_id) + " kinds=" + str(kinds))
|
|
||||||
|
|
||||||
merged: List[Dict[str, Any]] = []
|
|
||||||
for kind in kinds:
|
|
||||||
next_index: Optional[int] = kind * ITEM_NEXT_INDEX_FACTOR
|
|
||||||
page = 0
|
|
||||||
while next_index:
|
|
||||||
page += 1
|
|
||||||
resp = _get(
|
|
||||||
"GetUserItemApi",
|
|
||||||
UserItemRequestVO(userId=user_id, nextIndex=next_index, maxCount=100),
|
|
||||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
items = resp.get("userItemList") or []
|
|
||||||
if isinstance(items, dict):
|
|
||||||
items = [items]
|
|
||||||
merged.extend(items)
|
|
||||||
|
|
||||||
next_index = int(resp.get("nextIndex") or 0)
|
|
||||||
log_debug("item_kind=" + str(kind) + " 第 " + str(page) + " 页:"
|
|
||||||
+ str(len(items)) + " 条,nextIndex=" + str(next_index))
|
|
||||||
if page >= 200:
|
|
||||||
log_warning("item_kind=" + str(kind) + " 翻页超过 200 页,已中止")
|
|
||||||
break
|
|
||||||
|
|
||||||
log_info("道具获取完成:" + str(len(merged)) + " 条")
|
|
||||||
return {"userItemList": merged}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_kaleidx_scope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / GetGameKaleidxScopeApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
对应接口:
|
|
||||||
GetUserKaleidxScopeApi 请求体 UserIdRequestVO(userId)
|
|
||||||
响应 {userId, userKaleidxScopeList:[...]}
|
|
||||||
GetGameKaleidxScopeApi 请求体 **空对象 {}**(GameKaleidxScopeRequestVO 没有任何字段)
|
|
||||||
响应 {gameKaleidxScopeList:[{gateId, phaseId}]}
|
|
||||||
依据:
|
|
||||||
source/Assembly-CSharp/Net.VO.Mai2/UserKaleidxScopeRequestVO.cs
|
|
||||||
source/Assembly-CSharp/Net.VO.Mai2/GameKaleidxScopeRequestVO.cs(空类)
|
|
||||||
source/Assembly-CSharp/Net.VO.Mai2/GameKaleidxScopeResponseVO.cs
|
|
||||||
source/2026-10-05.log:15,119
|
|
||||||
|
|
||||||
★★ 重要区分(曾导致 userData 取空)
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
GameKaleidxScopeResponseVO 里**只有** gameKaleidxScopeList 字段,没有 userData。
|
|
||||||
玩家档案 userData 的正确来源是 GetUserDataApi(见 get_user_data.py)。
|
|
||||||
|
|
||||||
真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的
|
|
||||||
两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId,
|
|
||||||
多发字段会让服务端返回空内容。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserKaleidxScope, GetGameKaleidxScope
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info
|
|
||||||
from .maimai_net import MaimaiNetClient
|
|
||||||
from .user_getter_common import _get, EmptyRequestVO, UserIdRequestVO
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserKaleidxScope(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取玩家 KALEIDX 门进度(对应 GetUserKaleidxScopeApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userKaleidxScopeList
|
|
||||||
"""
|
|
||||||
log_info("取玩家 KALEIDX 门进度:user_id=" + str(user_id))
|
|
||||||
return _get("GetUserKaleidxScopeApi", UserIdRequestVO(userId=user_id), user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
|
|
||||||
|
|
||||||
def GetGameKaleidxScope(
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取全服 KALEIDX 门开放进度(对应 GetGameKaleidxScopeApi)。
|
|
||||||
|
|
||||||
★ 请求体是空对象 {} —— 该 RequestVO 一个字段都没有,**不要传 userId**。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 gameKaleidxScopeList
|
|
||||||
"""
|
|
||||||
log_info("取全服 KALEIDX 门进度")
|
|
||||||
return _get("GetGameKaleidxScopeApi", EmptyRequestVO(), 0,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_login_bonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家的登录奖励领取进度(真机 38 条,分两页)。
|
|
||||||
|
|
||||||
对应接口:GetUserLoginBonusApi
|
|
||||||
请求体 :UserLoginBonusRequestVO(userId + nextIndex + maxCount=20)
|
|
||||||
响应 :{userId, length, nextIndex, userLoginBonusList:[{bonusId,point,isCurrent,isComplete}]}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserLoginBonusRequestVO.cs
|
|
||||||
|
|
||||||
★ 回传 Upsert 时只回传 isCurrent=true 的那一条(本次签到的那条)。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserLoginBonus, GetUserLoginBonusAll
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info
|
|
||||||
from .maimai_net import MaimaiNetClient
|
|
||||||
from .user_getter_common import _get, UserLoginBonusRequestVO, get_all_pages
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserLoginBonus(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
next_index: int = 0,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户登录奖励(单页,对应 GetUserLoginBonusApi)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
next_index: 分页起始索引(0 = 首页)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userLoginBonusList
|
|
||||||
"""
|
|
||||||
log_info("取用户登录奖励:user_id=" + str(user_id) + " next_index=" + str(next_index))
|
|
||||||
return _get(
|
|
||||||
"GetUserLoginBonusApi",
|
|
||||||
UserLoginBonusRequestVO(userId=user_id, nextIndex=next_index, maxCount=20),
|
|
||||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserLoginBonusAll(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户登录奖励(自动翻页取全)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
{"userLoginBonusList": [...]}
|
|
||||||
"""
|
|
||||||
log_info("取用户登录奖励(全量):user_id=" + str(user_id))
|
|
||||||
items = get_all_pages(
|
|
||||||
"GetUserLoginBonusApi", user_id, "userLoginBonusList",
|
|
||||||
body_factory=lambda ni: UserLoginBonusRequestVO(
|
|
||||||
userId=user_id, nextIndex=ni or 0, maxCount=20),
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
log_info("登录奖励获取完成:" + str(len(items)) + " 条")
|
|
||||||
return {"userLoginBonusList": items}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_map.py —— 取用户地图进度(GetUserMapApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家各地图的推进距离(真机 104 条)。
|
|
||||||
|
|
||||||
对应接口:GetUserMapApi
|
|
||||||
请求体 :UserMapRequestVO(userId + nextIndex + maxCount=1000)
|
|
||||||
响应 :{userId, length, nextIndex, userMapList:[...]}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMapRequestVO.cs
|
|
||||||
|
|
||||||
★ Get 侧下发 100+ 条;Upsert 只回传「本次距离变化」的那 1 条
|
|
||||||
(真机:mapId=550003 的 distance 1424000 -> 1435000)。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserMap / GetUserMapAll
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info
|
|
||||||
from .maimai_net import MaimaiNetClient
|
|
||||||
from .user_getter_common import _get, UserIdPageRequestVO, get_all_pages
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserMap(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
next_index: int = 0,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户地图进度(单页,对应 GetUserMapApi)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
next_index: 分页起始索引(0 = 首页)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userMapList
|
|
||||||
"""
|
|
||||||
log_info("取用户地图进度:user_id=" + str(user_id) + " next_index=" + str(next_index))
|
|
||||||
return _get(
|
|
||||||
"GetUserMapApi",
|
|
||||||
UserIdPageRequestVO(userId=user_id, nextIndex=next_index, maxCount=1000),
|
|
||||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserMapAll(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户地图进度(自动翻页取全)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
{"userMapList": [...]}
|
|
||||||
"""
|
|
||||||
log_info("取用户地图进度(全量):user_id=" + str(user_id))
|
|
||||||
items = get_all_pages(
|
|
||||||
"GetUserMapApi", user_id, "userMapList",
|
|
||||||
body_factory=lambda ni: UserIdPageRequestVO(
|
|
||||||
userId=user_id, nextIndex=ni or 0, maxCount=1000),
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
log_info("地图进度获取完成:" + str(len(items)) + " 条")
|
|
||||||
return {"userMapList": items}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_mission.py —— 取用户任务与周数据(GetUserMissionDataApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家的任务进度与周常数据(真机 6 条任务)。
|
|
||||||
|
|
||||||
对应接口:GetUserMissionDataApi
|
|
||||||
请求体 :UserIdRequestVO
|
|
||||||
响应 :{userId, userWeeklyData:{...}, userMissionDataList:[...]}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMissionRequestVO.cs
|
|
||||||
|
|
||||||
★ 一个接口同时下发两个 Upsert 节点:
|
|
||||||
userMissionDataList 任务列表
|
|
||||||
userWeeklyData 周常数据(★ 是**对象**不是数组)
|
|
||||||
★ 两者回传 Upsert 时都要丢掉各自的 userId 字段。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserMissionData
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 GetUserMissionData(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户任务与周数据(对应 GetUserMissionDataApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict:{"userId":.., "userWeeklyData":{..}, "userMissionDataList":[..]}
|
|
||||||
"""
|
|
||||||
log_info("取用户任务与周数据:user_id=" + str(user_id))
|
|
||||||
return _get("GetUserMissionDataApi", UserIdRequestVO(userId=user_id), user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_music.py —— 取用户曲目成绩(GetUserMusicApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家全部曲目的历史成绩归档(真机 739 组)。
|
|
||||||
|
|
||||||
对应接口:GetUserMusicApi
|
|
||||||
请求体 :UserMusicRequestVO(userId + nextIndex + maxCount=50)
|
|
||||||
响应 :{userId, nextIndex, userMusicList:[{userMusicDetailList:[...], length}]}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMusicRequestVO.cs
|
|
||||||
source/Assembly-CSharp/Net.VO.Mai2/UserMusic.cs(外层按曲分组)
|
|
||||||
source/Assembly-CSharp/Net.VO.Mai2/GetUserMusicDetail.cs(内层按难度)
|
|
||||||
|
|
||||||
★ 响应是**两层嵌套**:外层每元素是一首歌,内层的 userMusicDetailList 才是
|
|
||||||
按 (musicId, level) 的成绩条目。取数时要摊平。
|
|
||||||
|
|
||||||
★ 回传 Upsert 时只回传本局玩的那一条(playCount +1),其余不动。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserMusicDetail, UserMusicDetailFlat
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info
|
|
||||||
from .maimai_net import MaimaiNetClient
|
|
||||||
from .user_getter_common import _get, UserMusicRequestVO, get_all_pages
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserMusicDetail(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
next_index: int = 0,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户曲目成绩(单页,对应 GetUserMusicApi)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
next_index: 分页起始索引(0 = 首页)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userMusicList(两层嵌套)
|
|
||||||
"""
|
|
||||||
log_info("取用户曲目成绩:user_id=" + str(user_id) + " next_index=" + str(next_index))
|
|
||||||
return _get(
|
|
||||||
"GetUserMusicApi",
|
|
||||||
UserMusicRequestVO(userId=user_id, nextIndex=next_index, maxCount=50),
|
|
||||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserMusicDetailAll(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户曲目成绩(自动翻页取全,已摊平成单层)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
{"userMusicList": [ {"musicId":..,"level":..,"playCount":..}, ... ]}
|
|
||||||
每元素是**单条 (musicId, level) 成绩**,不是按曲分组的嵌套结构。
|
|
||||||
"""
|
|
||||||
log_info("取用户曲目成绩(全量):user_id=" + str(user_id))
|
|
||||||
groups = get_all_pages(
|
|
||||||
"GetUserMusicApi", user_id, "userMusicList",
|
|
||||||
body_factory=lambda ni: UserMusicRequestVO(
|
|
||||||
userId=user_id, nextIndex=ni or 0, maxCount=50),
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
flat: List[Dict[str, Any]] = []
|
|
||||||
for group in groups:
|
|
||||||
if isinstance(group, dict):
|
|
||||||
details = group.get("userMusicDetailList") or []
|
|
||||||
if isinstance(details, dict):
|
|
||||||
details = [details]
|
|
||||||
flat.extend(details)
|
|
||||||
log_info("曲目成绩获取完成:" + str(len(flat)) + " 条(原始 "
|
|
||||||
+ str(len(groups)) + " 组)")
|
|
||||||
return {"userMusicList": flat}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_new_item.py —— 取新增道具(GetUserNewItemListApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
上传结算后查询「本次新增了哪些道具」(真机响应 userItemList 为空)。
|
|
||||||
|
|
||||||
对应接口:GetUserNewItemListApi
|
|
||||||
请求体 :UserNewItemListRequestVO(userId + version + userData[] + userPlaylogList[])
|
|
||||||
响应 :{userId, userItemList:[...]}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserNewItemListRequestVO.cs
|
|
||||||
source/2026-10-05.log:121 真机请求带 userData 与 userPlaylogList
|
|
||||||
|
|
||||||
★ 这是**结算之后**才调的接口,用于回查本局获得的新道具。
|
|
||||||
★ 请求体里的 userData / userPlaylogList 需要传,内容与 UpsertUserAll 一致。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserNewItemList
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, PLAY_VERSION_INT, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info
|
|
||||||
from .maimai_net import MaimaiNetClient, request_dict
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserNewItemListRequestVO:
|
|
||||||
"""请求 VO(对应 UserNewItemListRequestVO)。"""
|
|
||||||
userId: int
|
|
||||||
version: int = PLAY_VERSION_INT
|
|
||||||
userData: List[Any] = field(default_factory=list)
|
|
||||||
userPlaylogList: List[Any] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserNewItemList(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
version: int = PLAY_VERSION_INT,
|
|
||||||
user_data: Optional[List[Any]] = None,
|
|
||||||
user_playlog_list: Optional[List[Any]] = None,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取本次新增道具(对应 GetUserNewItemListApi)。
|
|
||||||
|
|
||||||
★ 这是**结算流程的前置接口**,真机在 UpsertUserAll 之前约 8 秒调用
|
|
||||||
(source/2026-10-05-new.log: 19:22:53 -> 19:23:02)。
|
|
||||||
|
|
||||||
★★ 传入的 user_data 必须是**游玩后**的档案,不是登录时的旧值。
|
|
||||||
真机实证(source/2026-10-05-new.log 的该请求):
|
|
||||||
userData[0].playCount = 1258 (登录时是 1258,本局后 1259)
|
|
||||||
userData[0].point = 4460 (登录时是 4110,本局后 4460)
|
|
||||||
userData[0].lastPlayDate = "2026-10-05 19:22:53.0"
|
|
||||||
即:**与 UpsertUserAll 里的 userData 完全一致**。
|
|
||||||
所以正确用法是先把本局演算跑完,再拿演算后的 userData 传进来。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
user_id: 用户 ID(必填)
|
|
||||||
version: ROM 版本整数(默认取 config.PLAY_VERSION_INT)
|
|
||||||
user_data: 玩家档案数组(通常传 [游玩后的 userData])
|
|
||||||
user_playlog_list: 本局成绩数组(通常传 [playlog])
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userItemList
|
|
||||||
"""
|
|
||||||
log_info("取新增道具:user_id=" + str(user_id) + " version=" + str(version))
|
|
||||||
return request_dict(
|
|
||||||
"GetUserNewItemListApi",
|
|
||||||
UserNewItemListRequestVO(
|
|
||||||
userId=user_id, version=version,
|
|
||||||
userData=list(user_data or []),
|
|
||||||
userPlaylogList=list(user_playlog_list or []),
|
|
||||||
),
|
|
||||||
user_id=user_id, client_id=client_id,
|
|
||||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
|
||||||
)
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_option.py —— 取用户游戏设置(GetUserOptionApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。
|
|
||||||
|
|
||||||
对应接口:GetUserOptionApi
|
|
||||||
请求体 :UserIdRequestVO
|
|
||||||
响应 :userOption 的字段直接平铺在响应顶层
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserOptionRequestVO.cs
|
|
||||||
|
|
||||||
★ 形态提醒:Get 侧响应是**平铺**的(顶层直接就是 optionKind/noteSpeed/...),
|
|
||||||
而回传 Upsert 时要包成单元素数组 userOption:[{...}]。
|
|
||||||
裁剪时要丢掉 tempoVolume(真机 Upsert 报文里没有这个字段)。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserOption
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 GetUserOption(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户游戏设置(对应 GetUserOptionApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,设置字段平铺在顶层
|
|
||||||
"""
|
|
||||||
log_info("取用户游戏设置:user_id=" + str(user_id))
|
|
||||||
return _get("GetUserOptionApi", UserIdRequestVO(userId=user_id), user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_preview.py —— 用户预览(GetUserPreviewApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。
|
|
||||||
|
|
||||||
对应接口:GetUserPreviewApi
|
|
||||||
请求体 :{"userId":.., "segaIdAuthKey":"", "token":"..", "clientId":".."}
|
|
||||||
source/Assembly-CSharp/Net.VO.Mai2/UserPreviewRequestVO.cs
|
|
||||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserPreview.cs:20-25
|
|
||||||
响应 :见 UserPreviewResponseVO(23 字段,含 errorId)
|
|
||||||
|
|
||||||
★ 真机调用时机:扫码拿到 userId/token 之后、UserLoginApi 之前
|
|
||||||
(source/2026-10-05.log:23)。
|
|
||||||
★ 客户端行为:isLogin=True 时拒绝继续登录,避免顶号
|
|
||||||
(source/Assembly-CSharp/Process.Entry.State/ConfirmPlay.cs:151)。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserPreview
|
|
||||||
pv = GetUserPreview(12345678, "TESTTOKEN0000000000")
|
|
||||||
print(pv.userName, pv.playerRating)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info, log_warning
|
|
||||||
from .maimai_net import MaimaiNetClient, request_dict
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserPreviewRequestVO:
|
|
||||||
"""请求 VO(对应 UserPreviewRequestVO)。"""
|
|
||||||
userId: int
|
|
||||||
segaIdAuthKey: str = ""
|
|
||||||
token: str = ""
|
|
||||||
clientId: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserPreviewResponseVO:
|
|
||||||
"""
|
|
||||||
响应 VO(对应 UserPreviewResponseVO + 真机响应字段)。
|
|
||||||
|
|
||||||
★ 字段顺序按 source/Assembly-CSharp/Net.VO.Mai2/UserPreviewResponseVO.cs
|
|
||||||
与 source/2026-10-05.log:24 的真机响应合并。
|
|
||||||
"""
|
|
||||||
userId: int = 0
|
|
||||||
userName: str = ""
|
|
||||||
isLogin: bool = False
|
|
||||||
lastGameId: Optional[str] = None
|
|
||||||
lastRomVersion: str = ""
|
|
||||||
lastDataVersion: str = ""
|
|
||||||
lastLoginDate: str = ""
|
|
||||||
lastPlayDate: str = ""
|
|
||||||
playerRating: int = 0
|
|
||||||
nameplateId: int = 0
|
|
||||||
iconId: int = 0
|
|
||||||
trophyId: int = 0
|
|
||||||
partnerId: int = 0
|
|
||||||
frameId: int = 0
|
|
||||||
dispRate: int = 0
|
|
||||||
totalAwake: int = 0
|
|
||||||
isNetMember: int = 0
|
|
||||||
dailyBonusDate: str = ""
|
|
||||||
headPhoneVolume: Optional[int] = None
|
|
||||||
isInherit: bool = False
|
|
||||||
banState: int = 0
|
|
||||||
errorId: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserPreview(
|
|
||||||
user_id: int,
|
|
||||||
token: str,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
sega_id_auth_key: str = "",
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> UserPreviewResponseVO:
|
|
||||||
"""
|
|
||||||
获取用户预览信息。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
user_id: 用户 ID(必填)
|
|
||||||
token: 会话令牌(必填,来自 ParseQRCode)
|
|
||||||
client_id: 机台短值(可选)
|
|
||||||
sega_id_auth_key: SEGA ID 鉴权 key(可选,通常为空)
|
|
||||||
timeout_ms: 超时毫秒(可选)
|
|
||||||
client: 可复用的 MaimaiNetClient(可选)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
UserPreviewResponseVO
|
|
||||||
"""
|
|
||||||
log_info("获取用户预览:user_id=" + str(user_id) + " client_id=" + client_id)
|
|
||||||
|
|
||||||
raw = request_dict(
|
|
||||||
"GetUserPreviewApi",
|
|
||||||
UserPreviewRequestVO(
|
|
||||||
userId=user_id,
|
|
||||||
segaIdAuthKey=sega_id_auth_key,
|
|
||||||
token=token,
|
|
||||||
clientId=client_id,
|
|
||||||
),
|
|
||||||
user_id=user_id, client_id=client_id,
|
|
||||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
known = {f for f in UserPreviewResponseVO.__dataclass_fields__}
|
|
||||||
resp = UserPreviewResponseVO(**{k: v for k, v in raw.items() if k in known})
|
|
||||||
|
|
||||||
if resp.errorId != 0:
|
|
||||||
log_warning("服务器返回 errorId=" + str(resp.errorId))
|
|
||||||
if resp.banState != 0:
|
|
||||||
log_warning("账号可能被封禁:banState=" + str(resp.banState))
|
|
||||||
if resp.isLogin:
|
|
||||||
log_warning("账号处于已登录状态(isLogin=True),继续登录会顶号")
|
|
||||||
|
|
||||||
log_info(
|
|
||||||
"用户预览获取完成:userName=" + resp.userName
|
|
||||||
+ " playerRating=" + str(resp.playerRating)
|
|
||||||
+ " isLogin=" + str(resp.isLogin)
|
|
||||||
+ " isNetMember=" + str(resp.isNetMember)
|
|
||||||
)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from .log_setup import log_error, setup_logging, teardown_logging
|
|
||||||
|
|
||||||
def _main() -> int:
|
|
||||||
setup_logging("userpreview_test")
|
|
||||||
try:
|
|
||||||
if len(sys.argv) < 3:
|
|
||||||
print("用法: python -m api.get_user_preview <user_id> <token> [client_id]")
|
|
||||||
return 1
|
|
||||||
cid = sys.argv[3] if len(sys.argv) > 3 else CLIENT_ID
|
|
||||||
pv = GetUserPreview(int(sys.argv[1]), sys.argv[2], client_id=cid)
|
|
||||||
print("")
|
|
||||||
for name in UserPreviewResponseVO.__dataclass_fields__:
|
|
||||||
print(" " + name.ljust(18) + "= " + str(getattr(pv, name)))
|
|
||||||
return 0
|
|
||||||
except Exception as exc:
|
|
||||||
log_error("获取用户预览失败:" + str(exc))
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
teardown_logging()
|
|
||||||
|
|
||||||
raise SystemExit(_main())
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_rating.py —— 取用户 Rating 明细(GetUserRatingApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家的 Rating 构成(旧谱 / 新谱 / 下一档候选 + 段位)。
|
|
||||||
|
|
||||||
对应接口:GetUserRatingApi
|
|
||||||
请求体 :UserIdRequestVO
|
|
||||||
响应 :{userId, userRating:{rating, ratingList, newRatingList, nextRatingList,
|
|
||||||
nextNewRatingList, udemae}}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserRatingRequestVO.cs
|
|
||||||
|
|
||||||
★ 回传 Upsert 时是**单元素数组** userRatingList:[{...}],不是对象。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserRating
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 GetUserRating(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户 Rating 明细(对应 GetUserRatingApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userRating 子对象
|
|
||||||
"""
|
|
||||||
log_info("取用户 Rating 明细:user_id=" + str(user_id))
|
|
||||||
return _get("GetUserRatingApi", UserIdRequestVO(userId=user_id), user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_recommend_music.py —— 取推荐曲目(GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
对应接口:
|
|
||||||
GetUserRecommendRateMusicApi 请求体 UserIdRequestVO
|
|
||||||
响应 {userId, userRecommendRateMusicIdList:[...]}
|
|
||||||
GetUserRecommendSelectMusicApi 请求体 UserIdRequestVO
|
|
||||||
响应 {userId, userRecommendSelectionMusicIdList:[...]}
|
|
||||||
依据:
|
|
||||||
source/Assembly-CSharp/Net.VO.Mai2/UserRecommendRateMusicRequestVO.cs
|
|
||||||
source/Assembly-CSharp/Net.VO.Mai2/UserRecommendSelectionMusicRequestVO.cs
|
|
||||||
source/2026-10-05.log:79,81
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 GetUserRecommendRateMusic(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取推荐上分曲目(对应 GetUserRecommendRateMusicApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userRecommendRateMusicIdList
|
|
||||||
"""
|
|
||||||
log_info("取推荐上分曲目:user_id=" + str(user_id))
|
|
||||||
return _get("GetUserRecommendRateMusicApi", UserIdRequestVO(userId=user_id), user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserRecommendSelectMusic(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取推荐选曲(对应 GetUserRecommendSelectMusicApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userRecommendSelectionMusicIdList
|
|
||||||
"""
|
|
||||||
log_info("取推荐选曲:user_id=" + str(user_id))
|
|
||||||
return _get("GetUserRecommendSelectMusicApi", UserIdRequestVO(userId=user_id), user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_region.py —— 取用户地区游玩统计(GetUserRegionApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家在各地区的游玩次数。
|
|
||||||
|
|
||||||
对应接口:GetUserRegionApi
|
|
||||||
请求体 :UserIdRequestVO
|
|
||||||
响应 :{userId, length, userRegionList:[{regionId,playCount,created}]}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserRegionRequestVO.cs
|
|
||||||
source/2026-10-05.log:77
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserRegion
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 GetUserRegion(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户地区游玩统计(对应 GetUserRegionApi)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userRegionList
|
|
||||||
"""
|
|
||||||
log_info("取用户地区统计:user_id=" + str(user_id))
|
|
||||||
return _get("GetUserRegionApi", UserIdRequestVO(userId=user_id), user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_rival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalMusicApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家的对手名单及其曲目成绩。
|
|
||||||
|
|
||||||
对应接口:
|
|
||||||
GetUserRivalDataApi 请求体 UserRivalRequestVO(userId + rivalId)
|
|
||||||
响应 {userId, userRivalData:{rivalId, rivalName}}
|
|
||||||
GetUserRivalMusicApi 请求体 UserRivalMusicRequestVO(userId + rivalId + nextIndex
|
|
||||||
+ userRivalMusicLevelList)
|
|
||||||
响应 {userId, rivalId, length, nextIndex, userRivalMusicList}
|
|
||||||
依据:
|
|
||||||
source/Assembly-CSharp/Net.VO.Mai2/UserRivalRequestVO.cs
|
|
||||||
source/Assembly-CSharp/Net.VO.Mai2/UserRivalMusicRequestVO.cs
|
|
||||||
source/2026-10-05.log:95,101
|
|
||||||
|
|
||||||
★ 对手 ID 需要调用方提供(先由 GetUserFavoriteItemApi 的 kind=2 取到)。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserRivalData, GetUserRivalMusic
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info
|
|
||||||
from .maimai_net import MaimaiNetClient
|
|
||||||
from .user_getter_common import (
|
|
||||||
_get,
|
|
||||||
UserRivalRequestVO,
|
|
||||||
UserRivalMusicRequestVO,
|
|
||||||
RIVAL_MUSIC_LEVELS,
|
|
||||||
get_all_pages,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserRivalData(
|
|
||||||
user_id: int,
|
|
||||||
rival_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取对手资料(对应 GetUserRivalDataApi)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
user_id: 用户 ID(必填)
|
|
||||||
rival_id: 对手 ID(必填)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userRivalData
|
|
||||||
"""
|
|
||||||
log_info("取对手资料:user_id=" + str(user_id) + " rival_id=" + str(rival_id))
|
|
||||||
return _get(
|
|
||||||
"GetUserRivalDataApi",
|
|
||||||
UserRivalRequestVO(userId=user_id, rivalId=rival_id),
|
|
||||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserRivalMusic(
|
|
||||||
user_id: int,
|
|
||||||
rival_id: int,
|
|
||||||
*,
|
|
||||||
next_index: int = 0,
|
|
||||||
levels: Optional[List[int]] = None,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取对手曲目成绩(单页,对应 GetUserRivalMusicApi)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
user_id: 用户 ID(必填)
|
|
||||||
rival_id: 对手 ID(必填)
|
|
||||||
next_index: 分页索引(0 = 首页)
|
|
||||||
levels: 难度档位列表(可选,默认 0/1/2/3/4/10)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userRivalMusicList
|
|
||||||
"""
|
|
||||||
lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS)
|
|
||||||
log_info("取对手曲目成绩:user_id=" + str(user_id) + " rival_id=" + str(rival_id)
|
|
||||||
+ " levels=" + str(lv))
|
|
||||||
return _get(
|
|
||||||
"GetUserRivalMusicApi",
|
|
||||||
UserRivalMusicRequestVO(
|
|
||||||
userId=user_id, rivalId=rival_id, nextIndex=next_index,
|
|
||||||
userRivalMusicLevelList=[{"level": x} for x in lv],
|
|
||||||
),
|
|
||||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserRivalMusicAll(
|
|
||||||
user_id: int,
|
|
||||||
rival_id: int,
|
|
||||||
*,
|
|
||||||
levels: Optional[List[int]] = None,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取对手曲目成绩(自动翻页取全)。
|
|
||||||
|
|
||||||
返回:
|
|
||||||
{"userRivalMusicList": [...]}
|
|
||||||
"""
|
|
||||||
lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS)
|
|
||||||
log_info("取对手曲目成绩(全量):user_id=" + str(user_id) + " rival_id=" + str(rival_id))
|
|
||||||
items = get_all_pages(
|
|
||||||
"GetUserRivalMusicApi", user_id, "userRivalMusicList",
|
|
||||||
body_factory=lambda ni: UserRivalMusicRequestVO(
|
|
||||||
userId=user_id, rivalId=rival_id, nextIndex=ni or 0,
|
|
||||||
userRivalMusicLevelList=[{"level": x} for x in lv]),
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
log_info("对手曲目获取完成:" + str(len(items)) + " 条")
|
|
||||||
return {"userRivalMusicList": items}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
get_user_shop_stock.py —— 取用户商店库存(GetUserShopStockApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
取玩家在各商店商品的已兑换次数。
|
|
||||||
|
|
||||||
对应接口:GetUserShopStockApi
|
|
||||||
请求体 :UserShopStockRequestVO(userId + shopItemIdList)
|
|
||||||
响应 :{userId, userShopStockList:[{shopItemId,tradeCount}]}
|
|
||||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserShopStockRequestVO.cs
|
|
||||||
source/2026-10-05.log:117 真机请求体带 100+ 个 shopItemId
|
|
||||||
|
|
||||||
★ shopItemIdList 需要调用方提供(来自游戏 Master 数据,脚本无法自行推导)。
|
|
||||||
不传时按空列表发送,服务端返回空 userShopStockList —— 这是合法的。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import GetUserShopStock
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, PLAY_SHOP_ITEM_IDS, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info
|
|
||||||
from .maimai_net import MaimaiNetClient
|
|
||||||
from .user_getter_common import _get, UserShopStockRequestVO
|
|
||||||
|
|
||||||
|
|
||||||
def GetUserShopStock(
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
shop_item_id_list: Optional[List[int]] = None,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
取用户商店库存(对应 GetUserShopStockApi)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
user_id: 用户 ID(必填)
|
|
||||||
shop_item_id_list: 商品 ID 清单(可选,默认取 config.PLAY_SHOP_ITEM_IDS)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
原始响应 dict,含 userShopStockList
|
|
||||||
"""
|
|
||||||
ids = list(shop_item_id_list) if shop_item_id_list is not None \
|
|
||||||
else list(PLAY_SHOP_ITEM_IDS)
|
|
||||||
|
|
||||||
log_info("取用户商店库存:user_id=" + str(user_id) + " 商品数=" + str(len(ids)))
|
|
||||||
return _get(
|
|
||||||
"GetUserShopStockApi",
|
|
||||||
UserShopStockRequestVO(userId=user_id, shopItemIdList=ids),
|
|
||||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
|
||||||
)
|
|
||||||
@@ -1,704 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
log_setup.py —— 统一日志模块(INFO / DEBUG 双模式)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
【两种模式】
|
|
||||||
|
|
||||||
INFO (默认) 只展示 api 脚本的运行情况和结果:
|
|
||||||
=== ParseQRCode 开始运行 ===
|
|
||||||
[接口说明] ...
|
|
||||||
[调用参数] ...
|
|
||||||
[运行结果] ...
|
|
||||||
=== ParseQRCode 运行结束 ===
|
|
||||||
|
|
||||||
DEBUG 在 INFO 基础上,额外展示「向 api 发送的数据」和「api 返回的数据」:
|
|
||||||
-> 请求 GetUserPreviewApi
|
|
||||||
URL : https://...
|
|
||||||
请求头 : {...}
|
|
||||||
请求体 : {...JSON 全文...}
|
|
||||||
<- 响应 GetUserPreviewApi
|
|
||||||
状态码 : 200
|
|
||||||
响应体 : {...JSON 全文...}
|
|
||||||
|
|
||||||
【模式从哪来】
|
|
||||||
|
|
||||||
优先级: setup_logging(level=...) 显式传参 > config.py 的 LOG_LEVEL > INFO
|
|
||||||
|
|
||||||
日常只改 config.py 一行:
|
|
||||||
|
|
||||||
LOG_LEVEL = "info" # 只看流程
|
|
||||||
LOG_LEVEL = "debug" # 连收发数据一起看
|
|
||||||
|
|
||||||
【日志文件(两种形式同时存在、同时写入)】
|
|
||||||
|
|
||||||
logs/latest.log 覆盖模式("w")—— 只包含【当次】主脚本运行产出的日志,
|
|
||||||
下一次运行主脚本时被覆盖
|
|
||||||
logs/YYYY-MM-DD.log 追加模式("a")—— 存放【当日】产生的所有日志
|
|
||||||
|
|
||||||
每行都带 [YYYY-MM-DD HH:MM:SS] 时间戳。
|
|
||||||
|
|
||||||
【主脚本怎么用】
|
|
||||||
|
|
||||||
from api import setup_logging, teardown_logging, ParseQRCode, UserLogin
|
|
||||||
|
|
||||||
def main():
|
|
||||||
setup_logging("我的主脚本") # 开头一句;api 调用从此自动记录
|
|
||||||
try:
|
|
||||||
user_id, token = ParseQRCode(qr) # 自动产出「开始运行 / 运行结束」
|
|
||||||
login = UserLogin(user_id, token)
|
|
||||||
finally:
|
|
||||||
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
|
|
||||||
|
|
||||||
api 函数不需要自己写 print,也不需要装饰器 —— api/__init__.py 在导入时就
|
|
||||||
统一套上了日志外壳(见 autowrap)。
|
|
||||||
|
|
||||||
【api 脚本怎么用】
|
|
||||||
|
|
||||||
from api import log_info, log_debug, log_request, log_response
|
|
||||||
|
|
||||||
def GetUserPreview(user_id, token, ...):
|
|
||||||
# 函数的第一行 docstring 会成为日志里的「[接口说明]」
|
|
||||||
# 例如: '''获取用户预览信息'''
|
|
||||||
log_info(f"请求用户预览 user_id={user_id}") # 可选,想额外记话时才写
|
|
||||||
|
|
||||||
log_request(url=url, body=req, headers=hdr, api_name="GetUserPreviewApi")
|
|
||||||
raw = _http_post(...)
|
|
||||||
log_response(status=200, body=raw, headers=hdr, api_name="GetUserPreviewApi")
|
|
||||||
|
|
||||||
return parsed
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import atexit
|
|
||||||
import functools
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Callable, Iterable, Optional
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 常量与全局状态
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
LEVEL_INFO = "info"
|
|
||||||
LEVEL_DEBUG = "debug"
|
|
||||||
|
|
||||||
_LEVEL_RANK = {LEVEL_INFO: 20, LEVEL_DEBUG: 10}
|
|
||||||
|
|
||||||
# 项目根目录(本文件在 api/ 下,根目录是上一级)
|
|
||||||
_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
_LOGS_DIR = os.path.join(_ROOT_DIR, "logs")
|
|
||||||
|
|
||||||
_SEPARATOR = "=" * 66
|
|
||||||
_INDENT = " "
|
|
||||||
|
|
||||||
# 全局状态
|
|
||||||
_session_started = False
|
|
||||||
_session_script_name = ""
|
|
||||||
_current_level = LEVEL_INFO
|
|
||||||
_value_max = 0
|
|
||||||
|
|
||||||
_date_log_path: Optional[str] = None
|
|
||||||
_latest_log_path: Optional[str] = None
|
|
||||||
_date_log_file = None
|
|
||||||
_latest_log_file = None
|
|
||||||
|
|
||||||
# 被自动跟踪的 api 函数名(供横幅展示)
|
|
||||||
_tracked_api_names: list = []
|
|
||||||
# 本次会话实际调用过的 api(按首次调用顺序)
|
|
||||||
_called_apis: list = []
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 模式判定
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def _normalize_level(level: Any) -> str:
|
|
||||||
"""把各种写法的模式名归一化成 'info' / 'debug'。"""
|
|
||||||
if level is None:
|
|
||||||
return LEVEL_INFO
|
|
||||||
text = str(level).strip().lower()
|
|
||||||
if text in ("debug", "dbg", "verbose", "详细", "调试"):
|
|
||||||
return LEVEL_DEBUG
|
|
||||||
return LEVEL_INFO
|
|
||||||
|
|
||||||
|
|
||||||
def _config_level() -> str:
|
|
||||||
"""从 config.py 读 LOG_LEVEL;读不到就用 INFO。"""
|
|
||||||
try:
|
|
||||||
import config # type: ignore
|
|
||||||
return _normalize_level(getattr(config, "LOG_LEVEL", LEVEL_INFO))
|
|
||||||
except Exception:
|
|
||||||
return LEVEL_INFO
|
|
||||||
|
|
||||||
|
|
||||||
def _config_value_max() -> int:
|
|
||||||
"""从 config.py 读 LOG_VALUE_MAX(0 = 不截断)。"""
|
|
||||||
try:
|
|
||||||
import config # type: ignore
|
|
||||||
return int(getattr(config, "LOG_VALUE_MAX", 0) or 0)
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def get_level() -> str:
|
|
||||||
"""返回当前日志模式('info' / 'debug')。"""
|
|
||||||
return _current_level
|
|
||||||
|
|
||||||
|
|
||||||
def is_debug() -> bool:
|
|
||||||
"""当前是否 DEBUG 模式。"""
|
|
||||||
return _LEVEL_RANK.get(_current_level, 20) <= _LEVEL_RANK[LEVEL_DEBUG]
|
|
||||||
|
|
||||||
|
|
||||||
def set_level(level: Any) -> str:
|
|
||||||
"""运行时切换模式(一般不用,改 config.py 即可)。返回切换后的模式。"""
|
|
||||||
global _current_level
|
|
||||||
_current_level = _normalize_level(level)
|
|
||||||
return _current_level
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 日志文件
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def _ensure_logs_dir() -> None:
|
|
||||||
os.makedirs(_LOGS_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_date_log_path() -> str:
|
|
||||||
return os.path.join(_LOGS_DIR, datetime.now().strftime("%Y-%m-%d") + ".log")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_latest_log_path() -> str:
|
|
||||||
return os.path.join(_LOGS_DIR, "latest.log")
|
|
||||||
|
|
||||||
|
|
||||||
def _write_files(message: str) -> None:
|
|
||||||
"""把一段文本写进两个日志文件(每行带时间戳)。"""
|
|
||||||
timestamp = datetime.now().strftime("[%Y-%m-%d %H:%M:%S]")
|
|
||||||
lines = message.splitlines() or [""]
|
|
||||||
payload = "".join(timestamp + line + "\n" for line in lines)
|
|
||||||
for handle in (_date_log_file, _latest_log_file):
|
|
||||||
if handle:
|
|
||||||
try:
|
|
||||||
handle.write(payload)
|
|
||||||
handle.flush()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _emit(message: str = "", *, console: bool = True) -> None:
|
|
||||||
"""统一出口:控制台 + 两个日志文件。"""
|
|
||||||
if console:
|
|
||||||
try:
|
|
||||||
print(message)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
print(message.encode("utf-8", "replace").decode("utf-8", "replace"))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_write_files(message)
|
|
||||||
|
|
||||||
|
|
||||||
def _close_log_files() -> None:
|
|
||||||
global _date_log_file, _latest_log_file
|
|
||||||
for name in ("_date_log_file", "_latest_log_file"):
|
|
||||||
handle = globals()[name]
|
|
||||||
if handle:
|
|
||||||
try:
|
|
||||||
handle.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
globals()[name] = None
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 自动跟踪:给 api 函数套日志外壳
|
|
||||||
# =============================================================================
|
|
||||||
#
|
|
||||||
# 目标:主脚本只写 setup_logging("名字") + 业务代码,
|
|
||||||
# 调用的每个 api 函数自动产出「===函数名 开始运行=== / ===函数名 运行结束===」,
|
|
||||||
# 调用方不必自己写装饰器,也不必自己埋点。
|
|
||||||
#
|
|
||||||
# 原理:api/__init__.py 在导入完各 api 脚本后调用 autowrap(globals(), _API_FUNCS),
|
|
||||||
# 把命名空间里的函数替换成带日志的版本 —— 于是 from api import ParseQRCode
|
|
||||||
# 拿到的已经是包装后的函数。
|
|
||||||
|
|
||||||
def _describe(func: Callable) -> str:
|
|
||||||
"""取函数 docstring 首行,作为「[接口说明]」。"""
|
|
||||||
doc = (func.__doc__ or "").strip()
|
|
||||||
if not doc:
|
|
||||||
return ""
|
|
||||||
return doc.splitlines()[0].strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _make_tracked(func: Callable) -> Callable:
|
|
||||||
"""生成带日志外壳的包装函数。"""
|
|
||||||
name = func.__name__
|
|
||||||
summary = _describe(func)
|
|
||||||
|
|
||||||
@functools.wraps(func)
|
|
||||||
def tracked(*args, **kwargs) -> Any:
|
|
||||||
_note_api_call(name)
|
|
||||||
|
|
||||||
_emit("")
|
|
||||||
_emit("=== " + name + " 开始运行 ===")
|
|
||||||
if summary:
|
|
||||||
_emit(_INDENT + "[接口说明] " + summary)
|
|
||||||
|
|
||||||
# 调用参数:DEBUG 展开多行 JSON,INFO 记单行摘要
|
|
||||||
if args:
|
|
||||||
if is_debug():
|
|
||||||
_emit(_INDENT + "[位置参数]")
|
|
||||||
_emit(_fmt_pretty(list(args)))
|
|
||||||
else:
|
|
||||||
_emit(_INDENT + "[位置参数] " + ", ".join(_fmt(a) for a in args))
|
|
||||||
if kwargs:
|
|
||||||
shown = {k: v for k, v in kwargs.items() if v is not None}
|
|
||||||
if shown:
|
|
||||||
if is_debug():
|
|
||||||
_emit(_INDENT + "[关键字参数]")
|
|
||||||
_emit(_fmt_pretty(shown))
|
|
||||||
else:
|
|
||||||
_emit(_INDENT + "[关键字参数] "
|
|
||||||
+ ", ".join(k + "=" + _fmt(v) for k, v in shown.items()))
|
|
||||||
|
|
||||||
t0 = time.monotonic()
|
|
||||||
try:
|
|
||||||
result = func(*args, **kwargs)
|
|
||||||
except BaseException as exc:
|
|
||||||
ms = int((time.monotonic() - t0) * 1000)
|
|
||||||
_emit(_INDENT + "[运行结果] 异常 " + type(exc).__name__ + ": "
|
|
||||||
+ str(exc) + "(耗时 " + str(ms) + " ms)")
|
|
||||||
_emit("=== " + name + " 运行结束 ===")
|
|
||||||
_emit("")
|
|
||||||
raise
|
|
||||||
|
|
||||||
ms = int((time.monotonic() - t0) * 1000)
|
|
||||||
_emit(_INDENT + "[运行结果] 成功 " + _fmt_result(result)
|
|
||||||
+ "(耗时 " + str(ms) + " ms)")
|
|
||||||
if is_debug() and result is not None:
|
|
||||||
_emit(_INDENT + "[返回数据]")
|
|
||||||
_emit(_fmt_pretty(result))
|
|
||||||
_emit("=== " + name + " 运行结束 ===")
|
|
||||||
_emit("")
|
|
||||||
return result
|
|
||||||
|
|
||||||
tracked.__mai_logged__ = True # type: ignore[attr-defined]
|
|
||||||
tracked.__mai_original__ = func # type: ignore[attr-defined]
|
|
||||||
return tracked
|
|
||||||
|
|
||||||
|
|
||||||
def autowrap(namespace: dict, names: Iterable[str]) -> list:
|
|
||||||
"""
|
|
||||||
给命名空间里的函数套上日志外壳(导入时调用,幂等)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
namespace: 目标命名空间,通常是 api/__init__.py 的 globals()
|
|
||||||
names: 要包装的函数名序列
|
|
||||||
|
|
||||||
返回:
|
|
||||||
实际完成包装的函数名列表
|
|
||||||
"""
|
|
||||||
wrapped = []
|
|
||||||
for name in names:
|
|
||||||
obj = namespace.get(name)
|
|
||||||
if obj is None or not callable(obj):
|
|
||||||
continue
|
|
||||||
if getattr(obj, "__mai_logged__", False):
|
|
||||||
wrapped.append(name)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
namespace[name] = _make_tracked(obj)
|
|
||||||
wrapped.append(name)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for n in wrapped:
|
|
||||||
if n not in _tracked_api_names:
|
|
||||||
_tracked_api_names.append(n)
|
|
||||||
return wrapped
|
|
||||||
|
|
||||||
|
|
||||||
def _note_api_call(name: str) -> None:
|
|
||||||
"""记录主脚本调用过的 api(去重,保留首次调用顺序)。"""
|
|
||||||
if name not in _called_apis:
|
|
||||||
_called_apis.append(name)
|
|
||||||
|
|
||||||
|
|
||||||
def get_called_apis() -> list:
|
|
||||||
"""返回本次会话实际调用过的 api 名称(按首次调用顺序)。"""
|
|
||||||
return list(_called_apis)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 会话:初始化 / 收尾
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def setup_logging(script_name: str, level: Any = None) -> str:
|
|
||||||
"""
|
|
||||||
初始化日志会话(主脚本开头调用一次)。
|
|
||||||
|
|
||||||
做三件事:
|
|
||||||
1. 确定日志模式(level 显式传参 > config.LOG_LEVEL > INFO)
|
|
||||||
2. 打开两个日志文件:日期日志(追加)+ latest.log(覆盖)
|
|
||||||
3. 打印会话横幅
|
|
||||||
|
|
||||||
参数:
|
|
||||||
script_name: 脚本名,显示在横幅上
|
|
||||||
level: 日志模式;None(默认)= 读 config.py 的 LOG_LEVEL
|
|
||||||
|
|
||||||
返回:
|
|
||||||
实际生效的模式('info' / 'debug')
|
|
||||||
"""
|
|
||||||
global _session_started, _session_script_name, _value_max
|
|
||||||
global _date_log_path, _latest_log_path, _date_log_file, _latest_log_file
|
|
||||||
|
|
||||||
set_level(level if level is not None else _config_level())
|
|
||||||
_value_max = _config_value_max()
|
|
||||||
|
|
||||||
if _session_started:
|
|
||||||
return _current_level
|
|
||||||
|
|
||||||
_ensure_logs_dir()
|
|
||||||
_session_script_name = script_name
|
|
||||||
_date_log_path = _get_date_log_path()
|
|
||||||
_latest_log_path = _get_latest_log_path()
|
|
||||||
|
|
||||||
_called_apis.clear()
|
|
||||||
|
|
||||||
_date_log_file = open(_date_log_path, "a", encoding="utf-8") # 追加:当日全部
|
|
||||||
_latest_log_file = open(_latest_log_path, "w", encoding="utf-8") # 覆盖:仅当次
|
|
||||||
|
|
||||||
_print_banner(script_name)
|
|
||||||
_session_started = True
|
|
||||||
return _current_level
|
|
||||||
|
|
||||||
|
|
||||||
def _print_banner(script_name: str) -> None:
|
|
||||||
mode_text = "DEBUG(含收发数据)" if is_debug() else "INFO(仅运行情况与结果)"
|
|
||||||
lines = [
|
|
||||||
_SEPARATOR,
|
|
||||||
_INDENT + "日志会话开始 —— " + script_name,
|
|
||||||
_SEPARATOR,
|
|
||||||
_INDENT * 2 + "主脚本 : " + script_name,
|
|
||||||
_INDENT * 2 + "开始时间 : " + datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
_INDENT * 2 + "日志模式 : " + mode_text,
|
|
||||||
_INDENT * 2 + "Python : " + platform.python_version()
|
|
||||||
+ " (" + platform.python_implementation() + " " + platform.machine() + ")",
|
|
||||||
_INDENT * 2 + "工作目录 : " + os.getcwd(),
|
|
||||||
_INDENT * 2 + "已挂载 api : " + str(len(_tracked_api_names))
|
|
||||||
+ " 个(自动跟踪,主脚本无需写日志代码)",
|
|
||||||
_INDENT * 2 + "当次日志 : " + str(_latest_log_path),
|
|
||||||
_INDENT * 2 + "日期日志 : " + str(_date_log_path),
|
|
||||||
_SEPARATOR,
|
|
||||||
]
|
|
||||||
_emit("\n".join(lines))
|
|
||||||
|
|
||||||
|
|
||||||
def teardown_logging() -> None:
|
|
||||||
"""结束日志会话(主脚本结尾调用;不调用也会在退出时自动收尾)。"""
|
|
||||||
global _session_started
|
|
||||||
if not _session_started:
|
|
||||||
return
|
|
||||||
|
|
||||||
lines = [_SEPARATOR, _INDENT + "本次运行调用的 api", _SEPARATOR]
|
|
||||||
if _called_apis:
|
|
||||||
for i, name in enumerate(_called_apis, 1):
|
|
||||||
lines.append(_INDENT * 2 + str(i).rjust(2) + ". " + name)
|
|
||||||
else:
|
|
||||||
lines.append(_INDENT * 2 + "(未调用任何 api)")
|
|
||||||
lines.append(_SEPARATOR)
|
|
||||||
lines.append(_INDENT + "日志会话结束 —— " + _session_script_name)
|
|
||||||
lines.append(_SEPARATOR)
|
|
||||||
|
|
||||||
_emit("\n".join(lines))
|
|
||||||
_close_log_files()
|
|
||||||
_session_started = False
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 值格式化
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def _truncate(text: str) -> str:
|
|
||||||
if _value_max and len(text) > _value_max:
|
|
||||||
return text[:_value_max] + "...(共 " + str(len(text)) + " 字符)"
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt(value: Any) -> str:
|
|
||||||
"""把一个值渲染成紧凑单行文本。"""
|
|
||||||
if isinstance(value, str):
|
|
||||||
return _truncate(value)
|
|
||||||
if value is None or isinstance(value, (int, float, bool)):
|
|
||||||
return repr(value)
|
|
||||||
if isinstance(value, (dict, list, tuple)):
|
|
||||||
try:
|
|
||||||
return _truncate(json.dumps(value, ensure_ascii=False,
|
|
||||||
separators=(",", ":"), default=str))
|
|
||||||
except Exception:
|
|
||||||
return _truncate(repr(value))
|
|
||||||
if hasattr(value, "__dict__"):
|
|
||||||
try:
|
|
||||||
data = {k: v for k, v in vars(value).items() if not k.startswith("_")}
|
|
||||||
return _truncate(json.dumps(data, ensure_ascii=False,
|
|
||||||
separators=(",", ":"), default=str))
|
|
||||||
except Exception:
|
|
||||||
return _truncate(repr(value))
|
|
||||||
return _truncate(repr(value))
|
|
||||||
|
|
||||||
|
|
||||||
def _to_plain(value: Any) -> Any:
|
|
||||||
"""把 dataclass / 普通对象递归转成可 JSON 化的结构。"""
|
|
||||||
if value is None or isinstance(value, (int, float, bool, str)):
|
|
||||||
return value
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return {k: _to_plain(v) for k, v in value.items()}
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
return [_to_plain(v) for v in value]
|
|
||||||
if hasattr(value, "__dataclass_fields__"):
|
|
||||||
return {k: _to_plain(getattr(value, k, None)) for k in value.__dataclass_fields__}
|
|
||||||
if hasattr(value, "__dict__"):
|
|
||||||
return {k: _to_plain(v) for k, v in vars(value).items() if not k.startswith("_")}
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_pretty(value: Any, indent: str = " ") -> str:
|
|
||||||
"""把 dict / list / VO 渲染成多行缩进的 JSON。DEBUG 下完整输出,不折叠。"""
|
|
||||||
data = _to_plain(value)
|
|
||||||
if isinstance(data, str):
|
|
||||||
stripped = data.strip()
|
|
||||||
if stripped[:1] in ("{", "["):
|
|
||||||
try:
|
|
||||||
data = json.loads(stripped)
|
|
||||||
except Exception:
|
|
||||||
return "\n".join(indent + line for line in data.splitlines())
|
|
||||||
try:
|
|
||||||
text = json.dumps(data, ensure_ascii=False, indent=2, default=str)
|
|
||||||
except Exception:
|
|
||||||
text = repr(data)
|
|
||||||
|
|
||||||
if _value_max and len(text) > _value_max:
|
|
||||||
text = text[:_value_max] + "\n" + indent + "...(共 " + str(len(text)) + " 字符,已截断)"
|
|
||||||
return "\n".join(indent + line for line in text.splitlines())
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_result(result: Any) -> str:
|
|
||||||
"""结果的紧凑摘要(INFO 用)。"""
|
|
||||||
if isinstance(result, list):
|
|
||||||
return "list(" + str(len(result)) + " 项)"
|
|
||||||
if isinstance(result, tuple):
|
|
||||||
return "tuple(" + str(len(result)) + " 项)"
|
|
||||||
if isinstance(result, dict):
|
|
||||||
return "dict(" + str(len(result)) + " 键)"
|
|
||||||
if isinstance(result, str):
|
|
||||||
return repr(result) if len(result) <= 60 else "str(" + str(len(result)) + " 字符)"
|
|
||||||
if hasattr(result, "__dataclass_fields__"):
|
|
||||||
return type(result).__name__ + "(" + str(len(result.__dataclass_fields__)) + " 字段)"
|
|
||||||
return repr(result)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 流程日志(INFO 可见)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def log_info(message: str) -> None:
|
|
||||||
"""普通信息 —— 相当于「会进日志文件的 print」。"""
|
|
||||||
_emit(str(message))
|
|
||||||
|
|
||||||
|
|
||||||
def log_step(title: str, *, index: int = 0, total: int = 0) -> None:
|
|
||||||
"""记一个运行步骤:log_step("扫码登录", index=1, total=5) -> [步骤 1/5] 扫码登录"""
|
|
||||||
if index and total:
|
|
||||||
_emit("[步骤 " + str(index) + "/" + str(total) + "] " + title)
|
|
||||||
elif index:
|
|
||||||
_emit("[步骤 " + str(index) + "] " + title)
|
|
||||||
else:
|
|
||||||
_emit("[步骤] " + title)
|
|
||||||
|
|
||||||
|
|
||||||
def log_result(message: Any) -> None:
|
|
||||||
"""记一条运行结果。"""
|
|
||||||
_emit("[结果] " + (message if isinstance(message, str) else _fmt(message)))
|
|
||||||
|
|
||||||
|
|
||||||
def log_warning(message: str) -> None:
|
|
||||||
"""警告(不影响流程,但需要留意)。"""
|
|
||||||
_emit("[警告] " + str(message))
|
|
||||||
|
|
||||||
|
|
||||||
def log_error(message: str) -> None:
|
|
||||||
"""错误。"""
|
|
||||||
_emit("[错误] " + str(message))
|
|
||||||
|
|
||||||
|
|
||||||
def log_section(title: str) -> None:
|
|
||||||
"""记一个分节(带分隔线),用于主脚本分段。"""
|
|
||||||
_emit("\n" + "-" * 60 + "\n" + title + "\n" + "-" * 60)
|
|
||||||
|
|
||||||
|
|
||||||
def log_field(name: str, value: Any, *, debug_only: bool = False) -> None:
|
|
||||||
"""
|
|
||||||
记一个「字段: 值」;DEBUG 下 dict / list / VO 自动多行展开。
|
|
||||||
|
|
||||||
log_field("user_id", 12345)
|
|
||||||
log_field("请求体", {"userId": 12345}, debug_only=True) # 只在 DEBUG 出现
|
|
||||||
"""
|
|
||||||
if debug_only and not is_debug():
|
|
||||||
return
|
|
||||||
if is_debug() and (isinstance(value, (dict, list)) or hasattr(value, "__dataclass_fields__")):
|
|
||||||
_emit(name + ":")
|
|
||||||
_emit(_fmt_pretty(value))
|
|
||||||
return
|
|
||||||
_emit(name + ": " + _fmt(value))
|
|
||||||
|
|
||||||
|
|
||||||
def log_debug(message: Any) -> None:
|
|
||||||
"""调试信息 —— 仅 DEBUG 模式输出。"""
|
|
||||||
if not is_debug():
|
|
||||||
return
|
|
||||||
_emit("[调试] " + _fmt(message))
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 收发数据日志(DEBUG 专属)—— api 脚本用它记录「发送的数据 / 返回的数据」
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def log_request(
|
|
||||||
url: str = "",
|
|
||||||
body: Any = None,
|
|
||||||
headers: Any = None,
|
|
||||||
*,
|
|
||||||
api_name: str = "",
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
记录一次「向 api 发送的数据」(仅 DEBUG 模式输出)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
url: 完整请求地址
|
|
||||||
body: 请求体(dict / VO / JSON 字符串),展开成 JSON 全文
|
|
||||||
headers: 请求头(dict)
|
|
||||||
api_name: 接口名
|
|
||||||
"""
|
|
||||||
if not is_debug():
|
|
||||||
return
|
|
||||||
_emit("")
|
|
||||||
_emit("-> 请求 " + api_name if api_name else "-> 请求")
|
|
||||||
if url:
|
|
||||||
_emit(" URL : " + str(url))
|
|
||||||
if headers:
|
|
||||||
_emit(" 请求头 : " + _fmt(headers))
|
|
||||||
if body is not None:
|
|
||||||
_emit(" 请求体 :")
|
|
||||||
_emit(_fmt_pretty(body))
|
|
||||||
|
|
||||||
|
|
||||||
def log_response(
|
|
||||||
status: Any = None,
|
|
||||||
body: Any = None,
|
|
||||||
headers: Any = None,
|
|
||||||
*,
|
|
||||||
api_name: str = "",
|
|
||||||
elapsed_ms: Optional[int] = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
记录一次「api 返回的数据」(仅 DEBUG 模式输出)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
status: HTTP 状态码
|
|
||||||
body: 响应体(JSON 字符串 / dict / VO)
|
|
||||||
headers: 响应头(dict)
|
|
||||||
api_name: 接口名
|
|
||||||
elapsed_ms: 耗时(毫秒)
|
|
||||||
"""
|
|
||||||
if not is_debug():
|
|
||||||
return
|
|
||||||
_emit("")
|
|
||||||
_emit("<- 响应 " + api_name if api_name else "<- 响应")
|
|
||||||
if status is not None:
|
|
||||||
_emit(" 状态码 : " + str(status))
|
|
||||||
if elapsed_ms is not None:
|
|
||||||
_emit(" 耗时 : " + str(elapsed_ms) + " ms")
|
|
||||||
if headers:
|
|
||||||
_emit(" 响应头 : " + _fmt(headers))
|
|
||||||
if body is not None:
|
|
||||||
_emit(" 响应体 :")
|
|
||||||
_emit(_fmt_pretty(body))
|
|
||||||
|
|
||||||
|
|
||||||
def log_payload(label: str, data: Any) -> None:
|
|
||||||
"""记录任意报文 / 数据结构(仅 DEBUG 模式输出)。"""
|
|
||||||
if not is_debug():
|
|
||||||
return
|
|
||||||
_emit("")
|
|
||||||
_emit(" " + label + " :")
|
|
||||||
_emit(_fmt_pretty(data))
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 兼容:装饰器与 logger 对象
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def api_log(func: Callable) -> Callable:
|
|
||||||
"""
|
|
||||||
api 函数装饰器 —— 与自动跟踪等价(重复包装会被跳过)。
|
|
||||||
|
|
||||||
一般【不需要】手写:api/__init__.py 的 autowrap 已经统一套好外壳。
|
|
||||||
这里保留是为了让 api 脚本单独运行(python -m api.xxx)时也能记录。
|
|
||||||
"""
|
|
||||||
if getattr(func, "__mai_logged__", False):
|
|
||||||
return func
|
|
||||||
return _make_tracked(func)
|
|
||||||
|
|
||||||
|
|
||||||
class _Logger:
|
|
||||||
"""兼容旧写法的 logger 对象;新代码直接用模块级函数即可。"""
|
|
||||||
|
|
||||||
def info(self, message: str) -> None:
|
|
||||||
log_info(message)
|
|
||||||
|
|
||||||
def warning(self, message: str) -> None:
|
|
||||||
log_warning(message)
|
|
||||||
|
|
||||||
def error(self, message: str) -> None:
|
|
||||||
log_error(message)
|
|
||||||
|
|
||||||
def debug(self, message: Any) -> None:
|
|
||||||
log_debug(message)
|
|
||||||
|
|
||||||
def step(self, title: str, **kwargs) -> None:
|
|
||||||
log_step(title, **kwargs)
|
|
||||||
|
|
||||||
def result(self, message: Any) -> None:
|
|
||||||
log_result(message)
|
|
||||||
|
|
||||||
|
|
||||||
_logger_instance = _Logger()
|
|
||||||
|
|
||||||
|
|
||||||
def get_logger() -> _Logger:
|
|
||||||
"""获取全局 logger 实例(等价于直接用模块级函数)。"""
|
|
||||||
return _logger_instance
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 退出时自动收尾
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def _auto_teardown() -> None:
|
|
||||||
try:
|
|
||||||
if _session_started:
|
|
||||||
teardown_logging()
|
|
||||||
else:
|
|
||||||
_close_log_files()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
atexit.register(_auto_teardown)
|
|
||||||
@@ -1,820 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
maimai_net.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
本模块是【唯一】直接和游戏服务器说话的地方,其余 api 脚本都通过它收发报文。
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
一、协议依据(全部来自 source/ 反编译源码)
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
1) 服务器基址与 API 路径
|
|
||||||
source/Assembly-CSharp/Manager/OperationManager.cs:225,338
|
|
||||||
ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
|
|
||||||
source/Assembly-CSharp/Net.Packet/Packet.cs:116
|
|
||||||
Client = NetHttpClient.Create(BaseUrl + Obfuscator(query.Api))
|
|
||||||
source/Assembly-CSharp/Net.Packet/Packet.cs:485
|
|
||||||
Obfuscator(s) = md5_hex_lower(s + "8bF76dE9")
|
|
||||||
source/Assembly-CSharp/Net.VO/NetQuery.cs:14
|
|
||||||
Api = api + "MaimaiChn"
|
|
||||||
|
|
||||||
=> 最终 URL = BASE_URL + md5(逻辑接口名 + "MaimaiChn" + "8bF76dE9")
|
|
||||||
|
|
||||||
已用真实日志验证:md5("GetGameSettingApiMaimaiChn" + "8bF76dE9")
|
|
||||||
= 83a5d5a20b062c5c2ad817460b8f7f76
|
|
||||||
(source/2026-10-05.log:1 的请求就是打到这个尾段)
|
|
||||||
|
|
||||||
2) 请求体封装(三段:JSON -> deflate -> AES)
|
|
||||||
source/Assembly-CSharp/Net/NetHttpClient.cs:262-300 RequestCallback
|
|
||||||
a. DeflateStream(CompressionMode.Compress) 压缩 JSON 字节
|
|
||||||
b. 手工拼 zlib 头 0x78 0x9C + 压缩数据 + Adler32(4 字节大端)
|
|
||||||
c. CipherAES.Encrypt(...) 整体 AES 加密
|
|
||||||
source/Assembly-CSharp/Net/CipherAES.cs:11-30
|
|
||||||
AES-256-CBC / PKCS7 / Key 32B / IV 16B
|
|
||||||
|
|
||||||
3) 响应体解封装(逆过程)
|
|
||||||
source/Assembly-CSharp/Net/NetHttpClient.cs:355-380 Decrypt + Decompress
|
|
||||||
a. AES 解密
|
|
||||||
b. 去掉前 2 字节 zlib 头与后 4 字节 Adler32
|
|
||||||
c. Deflate 解压 -> UTF-8 JSON
|
|
||||||
|
|
||||||
4) 请求头
|
|
||||||
source/Assembly-CSharp/Net/NetHttpClient.cs:174-190 Request()
|
|
||||||
Content-Type : application/json
|
|
||||||
charset : UTF-8
|
|
||||||
Mai-Encoding : 1.55 (DefaultHttpHeaderEncryption)
|
|
||||||
Content-Encoding: deflate (有请求体时)
|
|
||||||
User-Agent : <混淆后的API名>#<userId 或 clientId>
|
|
||||||
(source/Assembly-CSharp/Net/NetPacketUtil.cs:12-19)
|
|
||||||
number : <ping 毫秒>(非 Ping 接口;Packet.cs:189)
|
|
||||||
|
|
||||||
★ number 的语义:GameManager.PingMsec,只由 Ping 心跳包刷新
|
|
||||||
(PingChecker.cs:227 <- PacketPing.cs:34 的 sw.ElapsedMilliseconds)。
|
|
||||||
真机整局 53 个业务请求的 number 恒为同一个值(实测 63)。
|
|
||||||
所以调用方**应当先跑一次 Ping()**,之后的请求才会带上真实 number;
|
|
||||||
没跑过时 number = 0,与真机开机后第一屏的行为一致。
|
|
||||||
|
|
||||||
★ Cookie:★ UserLoginApi 的响应会下发
|
|
||||||
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
|
|
||||||
(Packet.cs:228-234 只在 UserLogin 响应上把它收进 CookieContainer,
|
|
||||||
之后 Packet.cs:190-193 每个带 userId 的请求都挂回去)。
|
|
||||||
Ping 等接口的响应头里没有 Set-Cookie,所以必须跑过登录才会拿到。
|
|
||||||
这是**真实的会话粘滞**,必须带着。
|
|
||||||
requests.Session 会自动收下并自动带上,无需额外代码 ——
|
|
||||||
但前提是**全程复用同一个 MaimaiNetClient**(用 get_default_client())。
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
二、★ 请求头集合:必须与真机一致(2026-10-06 真机抓包实证)
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
真机(Unity Mono 的 HttpWebRequest)实际发出的头**只有这几个**:
|
|
||||||
|
|
||||||
number / Content-Type / User-Agent / charset / Mai-Encoding /
|
|
||||||
Content-Encoding / (+ 登录后的 Cookie)
|
|
||||||
|
|
||||||
**没有 Accept,也没有 Connection。**
|
|
||||||
|
|
||||||
requests 默认会补 Accept: * / Connection: keep-alive,
|
|
||||||
已在 MaimaiNetClient.__init__ 里从 session.headers 删掉。
|
|
||||||
|
|
||||||
★ Accept-Encoding 是唯一的例外,必须**存在且为空**:
|
|
||||||
|
|
||||||
实测(GetGameSettingApi,2026-10-06):
|
|
||||||
Accept-Encoding: "" -> 200 + application/json
|
|
||||||
不发 / "gzip, deflate" / "identity" -> 200 + application/octet-stream(0 字节)
|
|
||||||
|
|
||||||
反推:真机能拿到 JSON 响应体,说明 Mono 发的也是**空值**的 Accept-Encoding
|
|
||||||
(Headers.AllKeys 里看不到它,是框架层加的)。
|
|
||||||
所以这里固定 Accept-Encoding: ""。
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
三、对外接口
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
MaimaiNetClient 客户端类(连接复用 + Cookie 管理)
|
|
||||||
get_default_client() 取全局默认客户端
|
|
||||||
request(...) 便捷函数:发一次请求并返回原始 JSON 字符串
|
|
||||||
request_vo(...) 便捷函数:发一次请求并返回映射后的 dataclass
|
|
||||||
|
|
||||||
加解密工具:aes_encrypt / aes_decrypt / deflate_compress / deflate_decompress
|
|
||||||
协议工具 :obfuscate_api_name / build_api_url / adler32
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import zlib
|
|
||||||
from dataclasses import dataclass, field, is_dataclass
|
|
||||||
from typing import Any, Dict, List, Optional, Type, TypeVar
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from Crypto.Cipher import AES
|
|
||||||
from Crypto.Util.Padding import pad, unpad
|
|
||||||
|
|
||||||
from config import CLIENT_ID, MAI_ENCODING, TIMEOUT_LONG_MS, TIMEOUT_MS
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 协议常量(原在 config.py,2026-10-06 下沉到本模块 —— 它们只服务于网络层)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
#: 游戏服务器(Maimai2Servlet)
|
|
||||||
#: 依据 source/Assembly-CSharp/Manager/OperationManager.cs:225,338
|
|
||||||
#: _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
|
|
||||||
BASE_URL = "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/"
|
|
||||||
|
|
||||||
#: AES 参数,依据 source/Assembly-CSharp/Net/CipherAES.cs:11-12
|
|
||||||
#: AesKey 32 字节 -> AES-256;AesIV 16 字节
|
|
||||||
AES_KEY = b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W"
|
|
||||||
AES_IV = b"F>;24DjU9W6ZsRH["
|
|
||||||
|
|
||||||
#: API 名混淆盐,依据 source/Assembly-CSharp/Net.Packet/Packet.cs:44
|
|
||||||
#: 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
|
|
||||||
OBFUSCATE_PARAM = "8bF76dE9"
|
|
||||||
|
|
||||||
from .log_setup import log_debug, log_error, log_request, log_response
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 协议常量
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
#: API 名后缀(NetQuery.cs:14 Api = api + "MaimaiChn")
|
|
||||||
API_SUFFIX = "MaimaiChn"
|
|
||||||
|
|
||||||
#: HTTP 头(NetHttpClient.cs:174-190)
|
|
||||||
HEADER_CONTENT_TYPE = "application/json"
|
|
||||||
HEADER_CHARSET = "UTF-8"
|
|
||||||
HEADER_CONTENT_ENCODING = "deflate"
|
|
||||||
|
|
||||||
#: AES 参数
|
|
||||||
AES_BLOCK_SIZE = 16
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 一、协议工具
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def obfuscate_api_name(api_name: str) -> str:
|
|
||||||
"""
|
|
||||||
对逻辑接口名做 MD5 混淆,得到 URL 尾段(对应 Packet.Obfuscator)。
|
|
||||||
|
|
||||||
逻辑名 "GetUserPreviewApi" -> "GetUserPreviewApiMaimaiChn"
|
|
||||||
-> md5(上面 + "8bF76dE9")
|
|
||||||
|
|
||||||
参数:
|
|
||||||
api_name: 逻辑接口名,**不要**自己带 MaimaiChn 后缀
|
|
||||||
|
|
||||||
返回:
|
|
||||||
32 位小写十六进制字符串
|
|
||||||
"""
|
|
||||||
full_name = api_name if api_name.endswith(API_SUFFIX) else api_name + API_SUFFIX
|
|
||||||
return hashlib.md5((full_name + OBFUSCATE_PARAM).encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def build_api_url(api_name: str) -> str:
|
|
||||||
"""构建完整的请求 URL。"""
|
|
||||||
return urljoin(BASE_URL, obfuscate_api_name(api_name))
|
|
||||||
|
|
||||||
|
|
||||||
def build_user_agent(api_name: str, user_id: int = 0, client_id: str = CLIENT_ID) -> str:
|
|
||||||
"""
|
|
||||||
构建 User-Agent(对应 NetPacketUtil.GetUserAgent)。
|
|
||||||
|
|
||||||
userId != 0 -> "<混淆API名>#<userId>"
|
|
||||||
userId == 0 -> "<混淆API名>#<clientId>"
|
|
||||||
"""
|
|
||||||
tail = user_id if user_id else client_id
|
|
||||||
return obfuscate_api_name(api_name) + "#" + str(tail)
|
|
||||||
|
|
||||||
|
|
||||||
def adler32(data: bytes) -> int:
|
|
||||||
"""
|
|
||||||
计算 Adler-32 校验和(对应 NetHttpClient.Adler32:437-452)。
|
|
||||||
|
|
||||||
★ 注意与 zlib.adler32 的区别:C# 端是自己实现的,
|
|
||||||
返回值是 (b << 16) + a,与 zlib.adler32 一致,这里保留原实现以便对照。
|
|
||||||
"""
|
|
||||||
a = 1
|
|
||||||
b = 0
|
|
||||||
for byte in data:
|
|
||||||
a = (a + byte) % 65521
|
|
||||||
b = (b + a) % 65521
|
|
||||||
return (b << 16) | a
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 二、加解密 / 压缩
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def aes_encrypt(data: bytes) -> bytes:
|
|
||||||
"""AES-256-CBC 加密(PKCS7 填充)。对应 CipherAES.Encrypt。"""
|
|
||||||
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
|
|
||||||
return cipher.encrypt(pad(data, AES_BLOCK_SIZE))
|
|
||||||
|
|
||||||
|
|
||||||
def aes_decrypt(data: bytes) -> bytes:
|
|
||||||
"""AES-256-CBC 解密(PKCS7 去填充)。对应 CipherAES.Decrypt。"""
|
|
||||||
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
|
|
||||||
return unpad(cipher.decrypt(data), AES_BLOCK_SIZE)
|
|
||||||
|
|
||||||
|
|
||||||
def deflate_compress(data: bytes) -> bytes:
|
|
||||||
"""
|
|
||||||
原始 deflate 压缩 + 手工拼 zlib 头与 Adler32。
|
|
||||||
|
|
||||||
对应 NetHttpClient.cs:262-300 RequestCallback:
|
|
||||||
memoryStream.WriteByte(120); // 0x78
|
|
||||||
memoryStream.WriteByte(156); // 0x9C
|
|
||||||
... 原始 deflate 数据 ...
|
|
||||||
... Adler32 大端 4 字节 ...
|
|
||||||
|
|
||||||
★ C# 的 DeflateStream 产生的是**原始 deflate**(无 zlib 头),
|
|
||||||
所以 Python 侧要用 compressobj(wbits=-zlib.MAX_WBITS)。
|
|
||||||
"""
|
|
||||||
compressor = zlib.compressobj(wbits=-zlib.MAX_WBITS)
|
|
||||||
compressed = compressor.compress(data) + compressor.flush()
|
|
||||||
checksum = adler32(data)
|
|
||||||
return bytes([0x78, 0x9C]) + compressed + checksum.to_bytes(4, "big")
|
|
||||||
|
|
||||||
|
|
||||||
def deflate_decompress(data: bytes) -> bytes:
|
|
||||||
"""
|
|
||||||
去掉 zlib 头与 Adler32 后做原始 deflate 解压。
|
|
||||||
|
|
||||||
对应 NetHttpClient.cs:355-380 Decompress:
|
|
||||||
_temporaryStream.Position = 2; // 跳过 2 字节头
|
|
||||||
_temporaryStream.SetLength(_temporaryStream.Length - 4); // 去掉 4 字节校验
|
|
||||||
"""
|
|
||||||
if len(data) < 6:
|
|
||||||
return b""
|
|
||||||
return zlib.decompress(data[2:-4], -zlib.MAX_WBITS)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 三、真机时间戳
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def local_utc_offset_seconds() -> int:
|
|
||||||
"""取本机时区相对 UTC 的偏移秒数(东八区 = +28800)。"""
|
|
||||||
offset = -time.timezone
|
|
||||||
if time.daylight and time.localtime().tm_isdst > 0:
|
|
||||||
offset = -time.altzone
|
|
||||||
return int(offset)
|
|
||||||
|
|
||||||
|
|
||||||
def login_date_time_now() -> int:
|
|
||||||
"""
|
|
||||||
生成真机 UserLoginApi 的 loginDateTime。
|
|
||||||
|
|
||||||
★ 真机算法(source/Assembly-CSharp/MAI2/DateTime.cs + PacketUserLogin.cs:29):
|
|
||||||
MAI2.DateTime.Now 返回 DateTime.UtcNow + LocalOffset,Kind = Unspecified;
|
|
||||||
new DateTimeOffset(该 Unspecified 值) 会再按本地时区加一次偏移,
|
|
||||||
最终 ToUnixTimeSeconds() = 标准 Unix 秒 + 本地时区偏移。
|
|
||||||
|
|
||||||
东八区即 now + 28800。
|
|
||||||
|
|
||||||
★ 为什么必须一致:
|
|
||||||
PacketUserLogin.cs:43 会把请求值回填进响应,
|
|
||||||
PacketUpsertUserAll.cs:31 / PacketUserLogout.cs:29 都复用该值,
|
|
||||||
与登录时不一致会被服务端拒绝。
|
|
||||||
"""
|
|
||||||
return int(time.time()) + local_utc_offset_seconds()
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 四、VO 序列化 / 反序列化
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def vo_to_json(obj: Any) -> str:
|
|
||||||
"""
|
|
||||||
把请求对象序列化成紧凑 JSON(对应 VOSerializer.Serialize)。
|
|
||||||
|
|
||||||
规则:
|
|
||||||
- dataclass -> 按字段声明顺序,跳过值为 None 的字段
|
|
||||||
- 带 __dict__ 的对象 -> 同上
|
|
||||||
- dict / 其他 -> 直接 json.dumps
|
|
||||||
"""
|
|
||||||
if obj is None:
|
|
||||||
return "{}"
|
|
||||||
if is_dataclass(obj) and not isinstance(obj, type):
|
|
||||||
data = {k: getattr(obj, k) for k in obj.__dataclass_fields__}
|
|
||||||
data = {k: v for k, v in data.items() if v is not None}
|
|
||||||
return json.dumps(data, separators=(",", ":"), ensure_ascii=False, default=_json_default)
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=_json_default)
|
|
||||||
if hasattr(obj, "__dict__"):
|
|
||||||
data = {k: v for k, v in vars(obj).items() if v is not None and not k.startswith("_")}
|
|
||||||
return json.dumps(data, separators=(",", ":"), ensure_ascii=False, default=_json_default)
|
|
||||||
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=_json_default)
|
|
||||||
|
|
||||||
|
|
||||||
def _json_default(value: Any) -> Any:
|
|
||||||
"""json.dumps 的兜底转换:dataclass / 普通对象 -> dict。"""
|
|
||||||
if is_dataclass(value) and not isinstance(value, type):
|
|
||||||
return {k: getattr(value, k) for k in value.__dataclass_fields__}
|
|
||||||
if hasattr(value, "__dict__"):
|
|
||||||
return {k: v for k, v in vars(value).items() if not k.startswith("_")}
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _convert_value(value: Any, target_type: Any) -> Any:
|
|
||||||
"""按目标类型递归转换嵌套 dataclass。"""
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(target_type, type) and is_dataclass(target_type) and isinstance(value, dict):
|
|
||||||
kwargs = {}
|
|
||||||
for fname, fdef in target_type.__dataclass_fields__.items():
|
|
||||||
if fname in value:
|
|
||||||
kwargs[fname] = _convert_value(value[fname], fdef.type)
|
|
||||||
return target_type(**kwargs)
|
|
||||||
|
|
||||||
origin = getattr(target_type, "__origin__", None)
|
|
||||||
if origin is list and isinstance(value, list):
|
|
||||||
args = getattr(target_type, "__args__", None)
|
|
||||||
if args and isinstance(args[0], type) and is_dataclass(args[0]):
|
|
||||||
return [_convert_value(v, args[0]) for v in value]
|
|
||||||
return value
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def vo_from_json(cls: Type[T], json_str: str) -> T:
|
|
||||||
"""
|
|
||||||
把响应 JSON 反序列化成 dataclass(对应 VOSerializer.Deserialize)。
|
|
||||||
|
|
||||||
★ 只填充类里声明过的字段 —— 服务端多给的字段会被忽略,不会报错。
|
|
||||||
"""
|
|
||||||
data = json.loads(json_str)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return data # type: ignore[return-value]
|
|
||||||
|
|
||||||
if is_dataclass(cls):
|
|
||||||
kwargs = {}
|
|
||||||
for fname, fdef in cls.__dataclass_fields__.items():
|
|
||||||
if fname in data:
|
|
||||||
kwargs[fname] = _convert_value(data[fname], fdef.type)
|
|
||||||
return cls(**kwargs)
|
|
||||||
|
|
||||||
obj = cls() # type: ignore[call-arg]
|
|
||||||
for k, v in data.items():
|
|
||||||
if hasattr(obj, k):
|
|
||||||
setattr(obj, k, v)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 五、网络客户端
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class MaimaiNetError(RuntimeError):
|
|
||||||
"""网络层 / 协议层错误。"""
|
|
||||||
|
|
||||||
|
|
||||||
class MaimaiNetClient:
|
|
||||||
"""
|
|
||||||
舞萌DX 游戏服务器客户端。
|
|
||||||
|
|
||||||
一次完整请求的流程:
|
|
||||||
|
|
||||||
请求方向:VO -> 紧凑 JSON -> 原始 deflate -> 0x78 0x9C + Adler32 -> AES-256-CBC -> HTTP POST
|
|
||||||
响应方向:HTTP Body -> AES 解密 -> 去 2 字节头 / 4 字节尾 -> deflate 解压 -> JSON -> VO
|
|
||||||
|
|
||||||
Cookie 由 requests.Session 自动管理;登录接口返回的 Set-Cookie 会被保存,
|
|
||||||
后续请求自动带上(对应 C# 的 CookieContainer,Packet.cs:183-190)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
base_url: str = BASE_URL,
|
|
||||||
timeout_ms: int = TIMEOUT_MS,
|
|
||||||
verify_ssl: bool = False,
|
|
||||||
) -> None:
|
|
||||||
self.base_url = base_url.rstrip("/") + "/"
|
|
||||||
self.timeout = timeout_ms / 1000.0
|
|
||||||
self.verify_ssl = verify_ssl
|
|
||||||
self.session = requests.Session()
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
# ★ 对齐真机的请求头集合(2026-10-06 真机抓包实证)
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
# 真机(Unity Mono 的 HttpWebRequest)在 Headers.AllKeys 里**只有**:
|
|
||||||
# number / Content-Type / User-Agent / charset / Mai-Encoding /
|
|
||||||
# Content-Encoding / (+ Cookie)
|
|
||||||
# 没有 Accept,也没有 Connection。
|
|
||||||
#
|
|
||||||
# 而 requests 默认会补上:
|
|
||||||
# Accept: */* <- 删掉(真机不发)
|
|
||||||
# Connection: keep-alive <- 删掉(真机不发;HTTP/1.1 本来就是长连接)
|
|
||||||
# Accept-Encoding <- 删掉(真机不发),改由 _build_headers 显式置空
|
|
||||||
#
|
|
||||||
# ★ Accept-Encoding 为什么必须**存在且为空**:
|
|
||||||
# 实测(GetGameSettingApi,2026-10-06):
|
|
||||||
# Accept-Encoding: "" -> 200 + application/json(384 B)
|
|
||||||
# 不发该头 / "gzip, deflate" / "identity"
|
|
||||||
# -> 200 + application/octet-stream(0 B)
|
|
||||||
# 所以这里从 Session 默认里删掉,再由 _build_headers 显式发一个空值。
|
|
||||||
self.session.headers.pop("Accept", None)
|
|
||||||
self.session.headers.pop("Connection", None)
|
|
||||||
self.session.headers.pop("Accept-Encoding", None)
|
|
||||||
|
|
||||||
#: ★ number 头的值(Packet.cs:189 GameManager.PingMsec)。
|
|
||||||
#: 只由 **Ping 心跳包**更新(对应 PingChecker.cs:227 PingSuccess),
|
|
||||||
#: 普通业务请求不会改动它 —— 真机整局 53 个请求的 number 恒为 63。
|
|
||||||
self.ping_msec: int = 0
|
|
||||||
|
|
||||||
if not verify_ssl:
|
|
||||||
try:
|
|
||||||
import urllib3
|
|
||||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
# 内部
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _build_headers(
|
|
||||||
self,
|
|
||||||
api_name: str,
|
|
||||||
user_id: int = 0,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
has_body: bool = True,
|
|
||||||
ping_msec: Optional[int] = None,
|
|
||||||
) -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
构建请求头(对应 NetHttpClient.Request:174-190 + Packet.ProcImpl:166-176)。
|
|
||||||
|
|
||||||
★ 关于 number 头:
|
|
||||||
source/Assembly-CSharp/Net.Packet/Packet.cs:187-194
|
|
||||||
|
|
||||||
if (!IsPingPacket())
|
|
||||||
{
|
|
||||||
Client.AddHeader("number", GameManager.PingMsec.ToString());
|
|
||||||
if (Query.UserId != 0L) { Client.AddCookie(Query.UserId); }
|
|
||||||
}
|
|
||||||
|
|
||||||
即**除 Ping 外的每个接口**都会带一个 number 头,值是本机到服务器的
|
|
||||||
ping 毫秒数(GameManager.PingMsec)。
|
|
||||||
|
|
||||||
★ number 的来源(源码链路,逐条可查):
|
|
||||||
Main/PingChecker.cs:166 StartPacket(new PacketPing(PingSuccess, PingError))
|
|
||||||
Net.Packet.Mai2/PacketPing.cs:34
|
|
||||||
_onDone((int)sw.ElapsedMilliseconds)
|
|
||||||
→ 值 = **Ping 包的往返毫秒**
|
|
||||||
Main/PingChecker.cs:227 GameManager.PingMsec = pingMsec
|
|
||||||
|
|
||||||
所以 number **只由 Ping 心跳刷新**,普通业务请求不会改它。
|
|
||||||
实测(gamehook 抓真机一局 70 个请求):
|
|
||||||
#1 ~ #10 number = 0 ← 还没跑过 Ping
|
|
||||||
#11 ~ #17 Ping 心跳(不带 number 头)
|
|
||||||
#18 ~ #70 number = 63 ← Ping 往返 63ms,此后恒定
|
|
||||||
本类在 request() 里对 Ping 包做同样的事:把往返毫秒写进 self.ping_msec。
|
|
||||||
|
|
||||||
★ 关于 Cookie(Packet.cs:190-193 + 228-234):
|
|
||||||
Client.AddCookie(Query.UserId) 把「登录时服务端 Set-Cookie 存下来的
|
|
||||||
CookieContainer」挂到本次请求上(NetHttpClient.cs:170-177)。
|
|
||||||
|
|
||||||
★ 实测 **UserLoginApi 的响应头里确实有**:
|
|
||||||
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
|
|
||||||
而 Ping 等接口的响应头里没有 Set-Cookie(只有 Date / Content-Type /
|
|
||||||
Content-Length / Connection / Pragma / Cache-Control)。
|
|
||||||
所以「登录前没有 Cookie、登录后一直带着 JSESSIONID」正是真机行为。
|
|
||||||
|
|
||||||
Cookie 由 requests.Session 自动管理:收到就存、后续自动带 —— 无需额外代码,
|
|
||||||
但**必须全程复用同一个 MaimaiNetClient**,否则会丢掉 JSESSIONID。
|
|
||||||
"""
|
|
||||||
headers = {
|
|
||||||
"Content-Type": HEADER_CONTENT_TYPE,
|
|
||||||
"charset": HEADER_CHARSET,
|
|
||||||
"Mai-Encoding": MAI_ENCODING,
|
|
||||||
"User-Agent": build_user_agent(api_name, user_id, client_id),
|
|
||||||
# ★ 必须显式置空,见模块 docstring 第二节
|
|
||||||
"Accept-Encoding": "",
|
|
||||||
}
|
|
||||||
if has_body:
|
|
||||||
headers["Content-Encoding"] = HEADER_CONTENT_ENCODING
|
|
||||||
# ★ number 头(Packet.cs:189):除 Ping 外的接口都要带
|
|
||||||
if not api_name.startswith("Ping"):
|
|
||||||
value = self.ping_msec if ping_msec is None else ping_msec
|
|
||||||
headers["number"] = str(int(value))
|
|
||||||
return headers
|
|
||||||
|
|
||||||
@property
|
|
||||||
def last_ping_msec(self) -> int:
|
|
||||||
"""number 头的当前值(= 最近一次 Ping 的往返毫秒)。兼容旧字段名。"""
|
|
||||||
return self.ping_msec
|
|
||||||
|
|
||||||
def _encrypt_request(self, json_str: str) -> bytes:
|
|
||||||
"""JSON -> deflate(zlib头+Adler32) -> AES。"""
|
|
||||||
return aes_encrypt(deflate_compress(json_str.encode("utf-8")))
|
|
||||||
|
|
||||||
def _decrypt_response(self, encrypted: bytes) -> str:
|
|
||||||
"""AES -> 去头尾 -> deflate -> UTF-8 字符串。"""
|
|
||||||
return deflate_decompress(aes_decrypt(encrypted)).decode("utf-8")
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
# 公开
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def request(
|
|
||||||
self,
|
|
||||||
api_name: str,
|
|
||||||
request_vo: Any = None,
|
|
||||||
*,
|
|
||||||
user_id: int = 0,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
extra_headers: Optional[Dict[str, str]] = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
发送一次请求,返回**原始 JSON 响应字符串**。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
api_name: 逻辑接口名(如 "GetUserPreviewApi",不要带 MaimaiChn)
|
|
||||||
request_vo: 请求体对象(dataclass / dict / None 表示空对象 {})
|
|
||||||
user_id: 用户 ID(0 表示未登录请求;影响 User-Agent 与 Cookie)
|
|
||||||
client_id: 客户端 ID
|
|
||||||
timeout_ms: 超时覆盖(毫秒)
|
|
||||||
extra_headers: 额外请求头(如 number)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
响应 JSON 字符串
|
|
||||||
|
|
||||||
异常:
|
|
||||||
MaimaiNetError: 网络异常 / 空响应 / 解密或解压失败
|
|
||||||
"""
|
|
||||||
url = urljoin(self.base_url, obfuscate_api_name(api_name))
|
|
||||||
timeout = (timeout_ms or self.timeout * 1000) / 1000.0
|
|
||||||
|
|
||||||
request_json = vo_to_json(request_vo)
|
|
||||||
body = self._encrypt_request(request_json)
|
|
||||||
|
|
||||||
headers = self._build_headers(api_name, user_id, client_id, has_body=True)
|
|
||||||
if extra_headers:
|
|
||||||
headers.update(extra_headers)
|
|
||||||
|
|
||||||
# ★ DEBUG 模式:记录「向 api 发送的数据」
|
|
||||||
log_request(url=url, body=request_json, headers=headers, api_name=api_name)
|
|
||||||
|
|
||||||
t0 = time.monotonic()
|
|
||||||
try:
|
|
||||||
response = self.session.post(
|
|
||||||
url,
|
|
||||||
data=body,
|
|
||||||
headers=headers,
|
|
||||||
timeout=timeout,
|
|
||||||
verify=self.verify_ssl,
|
|
||||||
)
|
|
||||||
except requests.exceptions.RequestException as exc:
|
|
||||||
log_error("接口 " + api_name + " 请求失败:" + str(exc))
|
|
||||||
raise MaimaiNetError("接口 " + api_name + " 请求失败:" + str(exc)) from exc
|
|
||||||
|
|
||||||
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
|
||||||
# ★ 只有 Ping 包会刷新 number(对应 PingChecker.cs:224-229 PingSuccess)。
|
|
||||||
# 普通业务包不动它,否则 number 会随每个包的耗时抖动,与真机不符。
|
|
||||||
if api_name.startswith("Ping"):
|
|
||||||
self.ping_msec = elapsed_ms
|
|
||||||
content = response.content
|
|
||||||
|
|
||||||
# 0 字节响应:请求未进入业务层
|
|
||||||
if len(content) == 0:
|
|
||||||
detail = (
|
|
||||||
"接口 " + api_name + " 返回 HTTP " + str(response.status_code)
|
|
||||||
+ ",响应体为 0 字节(空响应)。\n"
|
|
||||||
" 含义:服务端没有返回任何内容,请求很可能未进入业务层。\n"
|
|
||||||
" 排查:userId/token 是否有效、接口名与请求体字段是否与源码一致、"
|
|
||||||
"Accept-Encoding 是否被置空。\n"
|
|
||||||
" 响应头:" + repr(dict(response.headers))
|
|
||||||
)
|
|
||||||
log_error(detail)
|
|
||||||
raise MaimaiNetError(detail)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response_json = self._decrypt_response(content)
|
|
||||||
except ValueError as exc:
|
|
||||||
detail = (
|
|
||||||
"接口 " + api_name + " 响应解密失败(HTTP " + str(response.status_code)
|
|
||||||
+ ",body " + str(len(content)) + " 字节):" + str(exc) + "\n"
|
|
||||||
" 含义:服务器返回了内容,但不是合法的 AES 密文,或长度不是 16 的倍数。"
|
|
||||||
)
|
|
||||||
log_error(detail)
|
|
||||||
raise MaimaiNetError(detail) from exc
|
|
||||||
except zlib.error as exc:
|
|
||||||
detail = (
|
|
||||||
"接口 " + api_name + " 响应解压失败(HTTP " + str(response.status_code)
|
|
||||||
+ ",body " + str(len(content)) + " 字节):" + str(exc) + "\n"
|
|
||||||
" 含义:AES 解密成功但 deflate 解压失败,响应不是预期的压缩格式"
|
|
||||||
"(可能是网关/防火墙返回的页面)。"
|
|
||||||
)
|
|
||||||
log_error(detail)
|
|
||||||
raise MaimaiNetError(detail) from exc
|
|
||||||
|
|
||||||
if not response_json:
|
|
||||||
detail = (
|
|
||||||
"接口 " + api_name + " 返回空明文(HTTP " + str(response.status_code)
|
|
||||||
+ ",密文 " + str(len(content)) + " 字节,解密后为空串)。\n"
|
|
||||||
" 含义:链路与加密都正常,但服务端没有返回内容。\n"
|
|
||||||
" 排查:userId / token 是否失效、keychip 与该卡是否匹配、该卡是否正在游戏中。"
|
|
||||||
)
|
|
||||||
log_error(detail)
|
|
||||||
raise MaimaiNetError(detail)
|
|
||||||
|
|
||||||
# ★ DEBUG 模式:记录「api 返回的数据」
|
|
||||||
try:
|
|
||||||
shown_body: Any = json.loads(response_json)
|
|
||||||
except Exception:
|
|
||||||
shown_body = response_json
|
|
||||||
log_response(
|
|
||||||
status=response.status_code,
|
|
||||||
body=shown_body,
|
|
||||||
headers={k: v for k, v in response.headers.items()},
|
|
||||||
api_name=api_name,
|
|
||||||
elapsed_ms=elapsed_ms,
|
|
||||||
)
|
|
||||||
|
|
||||||
return response_json
|
|
||||||
|
|
||||||
def request_vo(
|
|
||||||
self,
|
|
||||||
api_name: str,
|
|
||||||
request_vo: Any,
|
|
||||||
response_cls: Type[T],
|
|
||||||
*,
|
|
||||||
user_id: int = 0,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
extra_headers: Optional[Dict[str, str]] = None,
|
|
||||||
) -> T:
|
|
||||||
"""发送请求并把响应映射成 dataclass。"""
|
|
||||||
raw = self.request(
|
|
||||||
api_name, request_vo,
|
|
||||||
user_id=user_id, client_id=client_id,
|
|
||||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
|
||||||
)
|
|
||||||
return vo_from_json(response_cls, raw)
|
|
||||||
|
|
||||||
def request_dict(
|
|
||||||
self,
|
|
||||||
api_name: str,
|
|
||||||
request_vo: Any = None,
|
|
||||||
*,
|
|
||||||
user_id: int = 0,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
extra_headers: Optional[Dict[str, str]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
发送请求并返回**原始响应 dict**。
|
|
||||||
|
|
||||||
★ 取数脚本一律用这个:upsertUserAll 需要响应原文,
|
|
||||||
映射成 dataclass 会丢掉服务端多给的字段。
|
|
||||||
"""
|
|
||||||
raw = self.request(
|
|
||||||
api_name, request_vo,
|
|
||||||
user_id=user_id, client_id=client_id,
|
|
||||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
|
||||||
)
|
|
||||||
data = json.loads(raw)
|
|
||||||
return data if isinstance(data, dict) else {"_data": data}
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
"""关闭底层会话。"""
|
|
||||||
try:
|
|
||||||
self.session.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def __enter__(self) -> "MaimaiNetClient":
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc, tb) -> None:
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 六、全局默认客户端
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
_default_client: Optional[MaimaiNetClient] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_client() -> MaimaiNetClient:
|
|
||||||
"""获取(必要时创建)全局默认客户端 —— 连接与 Cookie 会自动复用。"""
|
|
||||||
global _default_client
|
|
||||||
if _default_client is None:
|
|
||||||
_default_client = MaimaiNetClient()
|
|
||||||
return _default_client
|
|
||||||
|
|
||||||
|
|
||||||
def close_default_client() -> None:
|
|
||||||
"""关闭全局默认客户端(一般不需要手动调用)。"""
|
|
||||||
global _default_client
|
|
||||||
if _default_client is not None:
|
|
||||||
_default_client.close()
|
|
||||||
_default_client = None
|
|
||||||
|
|
||||||
|
|
||||||
def request(
|
|
||||||
api_name: str,
|
|
||||||
request_vo: Any = None,
|
|
||||||
*,
|
|
||||||
user_id: int = 0,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
extra_headers: Optional[Dict[str, str]] = None,
|
|
||||||
) -> str:
|
|
||||||
"""便捷函数:用默认客户端发一次请求,返回原始 JSON 字符串。"""
|
|
||||||
net = client or get_default_client()
|
|
||||||
return net.request(
|
|
||||||
api_name, request_vo,
|
|
||||||
user_id=user_id, client_id=client_id,
|
|
||||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def request_dict(
|
|
||||||
api_name: str,
|
|
||||||
request_vo: Any = None,
|
|
||||||
*,
|
|
||||||
user_id: int = 0,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
extra_headers: Optional[Dict[str, str]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""便捷函数:用默认客户端发一次请求,返回原始响应 dict。"""
|
|
||||||
net = client or get_default_client()
|
|
||||||
return net.request_dict(
|
|
||||||
api_name, request_vo,
|
|
||||||
user_id=user_id, client_id=client_id,
|
|
||||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def request_vo(
|
|
||||||
api_name: str,
|
|
||||||
request_vo: Any,
|
|
||||||
response_cls: Type[T],
|
|
||||||
*,
|
|
||||||
user_id: int = 0,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
extra_headers: Optional[Dict[str, str]] = None,
|
|
||||||
) -> T:
|
|
||||||
"""便捷函数:用默认客户端发一次请求,返回映射后的 dataclass。"""
|
|
||||||
net = client or get_default_client()
|
|
||||||
return net.request_vo(
|
|
||||||
api_name, request_vo, response_cls,
|
|
||||||
user_id=user_id, client_id=client_id,
|
|
||||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 七、通用请求 / 响应 VO
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EmptyRequestVO:
|
|
||||||
"""空请求体 —— 对应 C# 里没有任何字段的 RequestVO(如 PingRequestVO)。"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserIdRequestVO:
|
|
||||||
"""只有 userId 的请求体(对应 UserDetailRequestVO / UserCharacterRequestVO 等)。"""
|
|
||||||
userId: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ReturnCodeVO:
|
|
||||||
"""通用「只有 returnCode」的响应体。"""
|
|
||||||
returnCode: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 单独运行:协议自检(不发业务请求)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("=== maimai_net 协议自检 ===")
|
|
||||||
print("BASE_URL :", BASE_URL)
|
|
||||||
print("GetGameSettingApi :", build_api_url("GetGameSettingApi"))
|
|
||||||
print(" 期望尾段 : 83a5d5a20b062c5c2ad817460b8f7f76")
|
|
||||||
print("User-Agent 示例 :", build_user_agent("UserLoginApi", 12345678))
|
|
||||||
|
|
||||||
sample = '{"userId":12345678,"token":"abc"}'
|
|
||||||
packed = deflate_compress(sample.encode("utf-8"))
|
|
||||||
print("deflate 头/尾 :", packed[:2].hex(), packed[-4:].hex(),
|
|
||||||
" Adler32 =", hex(adler32(sample.encode("utf-8"))))
|
|
||||||
assert deflate_decompress(packed).decode("utf-8") == sample
|
|
||||||
|
|
||||||
enc = aes_encrypt(packed)
|
|
||||||
assert aes_decrypt(enc) == packed
|
|
||||||
print("AES 往返 : OK(密文", len(enc), "字节,16 的倍数:", len(enc) % 16 == 0, ")")
|
|
||||||
print("自检通过")
|
|
||||||
-95
@@ -1,95 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
ping.py —— 心跳探活(PingMaimaiChn)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
功能:向游戏服务器发一次心跳,确认链路可达。
|
|
||||||
|
|
||||||
对应接口:PingMaimaiChn
|
|
||||||
请求体 :{}(source/Assembly-CSharp/Net.VO.Mai2/PingRequestVO.cs 是空类)
|
|
||||||
响应 :{"result": "Pong"}
|
|
||||||
|
|
||||||
★ 接口名(容易写错,注意):
|
|
||||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketPing.cs:22
|
|
||||||
new NetQuery<PingRequestVO, PingResponseVO>("Ping", 0uL);
|
|
||||||
客户端传的逻辑名是 **"Ping"**,NetQuery.cs:14 再补上 "MaimaiChn" 后缀,
|
|
||||||
所以混淆用的全名是 "PingMaimaiChn":
|
|
||||||
md5("PingMaimaiChn" + "8bF76dE9") = 3bae8c1162b3148abcfc38a89c03e3e7
|
|
||||||
本模块传 "Ping" 即可,obfuscate_api_name 会自动补后缀。
|
|
||||||
|
|
||||||
(Packet.cs:96 用 Query.Api.StartsWith("Ping") 判断是否心跳包,
|
|
||||||
也是基于补完后缀的 "PingMaimaiChn"。)
|
|
||||||
|
|
||||||
★ 为什么要跑它(不只是探活):
|
|
||||||
Packet.cs:189 非 Ping 接口都会带 HTTP 头 number = GameManager.PingMsec
|
|
||||||
PingChecker.cs:227 GameManager.PingMsec = Ping 包的往返毫秒
|
|
||||||
|
|
||||||
所以 **Ping 是 number 头的唯一来源**。先 Ping 一次,后面的业务请求才会
|
|
||||||
带上真实 number(真机实测 63ms);不 Ping 则 number = 0,
|
|
||||||
与真机开机后第一屏(还没跑过心跳)的行为一致。
|
|
||||||
MaimaiNetClient 在收到 Ping 响应后会自动把往返毫秒记进 client.ping_msec。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import Ping
|
|
||||||
if Ping():
|
|
||||||
print("服务器可达")
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_info, log_warning
|
|
||||||
from .maimai_net import MaimaiNetClient, request_dict
|
|
||||||
|
|
||||||
|
|
||||||
def Ping(
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
心跳探活。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
client_id: 机台短值(可选)
|
|
||||||
timeout_ms: 超时毫秒(可选)
|
|
||||||
client: 可复用的 MaimaiNetClient(可选)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
True = 服务器返回 "Pong"
|
|
||||||
"""
|
|
||||||
log_info("发送心跳探活")
|
|
||||||
|
|
||||||
raw = request_dict(
|
|
||||||
"Ping", {},
|
|
||||||
user_id=0, client_id=client_id,
|
|
||||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = raw.get("result", "")
|
|
||||||
ok = (result == "Pong")
|
|
||||||
if ok:
|
|
||||||
log_info("心跳正常:result=Pong")
|
|
||||||
else:
|
|
||||||
log_warning("心跳返回异常:result=" + str(result))
|
|
||||||
return ok
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
from .log_setup import setup_logging, teardown_logging
|
|
||||||
|
|
||||||
def _main() -> int:
|
|
||||||
setup_logging("ping_test")
|
|
||||||
try:
|
|
||||||
ok = Ping()
|
|
||||||
print("")
|
|
||||||
print("服务器可达:" + str(ok))
|
|
||||||
return 0 if ok else 1
|
|
||||||
finally:
|
|
||||||
teardown_logging()
|
|
||||||
|
|
||||||
raise SystemExit(_main())
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,572 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
r"""
|
|
||||||
upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
功能:把「买了一张功能票」这件事上报给服务器,并把该票的持有数 +1。
|
|
||||||
|
|
||||||
对应接口:UpsertUserChargelogApi
|
|
||||||
请求体 :UserChargelogRequestVO(4 字段,顺序照 C# 声明)
|
|
||||||
source/Assembly-CSharp/Net.VO.Mai2/UserChargelogRequestVO.cs
|
|
||||||
public ulong userId;
|
|
||||||
public UserChargelog userChargelog;
|
|
||||||
public GetUserCharge userCharge;
|
|
||||||
public long loginDateTime;
|
|
||||||
响应 :{returnCode, apiName}
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
一、真机的购买流程(源码逐行可查)
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
|
|
||||||
├─ ticket.ticketKind.id == 1(付费票)
|
|
||||||
├─ 从 OperationManager.GetGameChargeDataList() 找该 chargeId 的 price
|
|
||||||
│ (= GetGameChargeApi 的响应)
|
|
||||||
├─ TicketManager.cs:207 SetBuyPaidTicketData(playerId, ticketId)
|
|
||||||
│ ├─ ChargeList 里已有该 chargeId -> stock++、更新 purchaseDate / validDate
|
|
||||||
│ └─ 没有 -> 新增一条 stock=1
|
|
||||||
└─ TicketManager.cs:234 RequestUserCharge(playerId)
|
|
||||||
├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId)
|
|
||||||
│ -> 取**购买后**(stock > 0)的那条 GetUserCharge
|
|
||||||
├─ VOExtensions.cs:403 ExportUserChargelog(ticketId)
|
|
||||||
│ -> chargeId = ticketId
|
|
||||||
│ price = DataManager.GetTicket(ticketId).creditNum ★ 不是 money price
|
|
||||||
│ purchaseDate = TimeManager.GetNowDateString()
|
|
||||||
│ playCount = 玩家总游玩次数
|
|
||||||
│ playerRating = 玩家 Rating
|
|
||||||
│ placeId / regionId / clientId = 当前机台
|
|
||||||
└─ 发 UpsertUserChargelogApi
|
|
||||||
|
|
||||||
★ 成功后 TicketManager.cs:255 RequestSuccess 会把本地 stock 再 --,
|
|
||||||
因为那张票马上就被这一局用掉了。本模块只负责「买」,
|
|
||||||
所以默认传的是**购买后**的库存(现有 + 1)。
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
二、票的有效期算法
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
Manager/TicketManager.cs:564
|
|
||||||
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
|
||||||
|
|
||||||
真机验证(source/2026-10-05.log:123 的 userChargeList):
|
|
||||||
chargeId=5 purchaseDate="2026-10-04 18:22:10"
|
|
||||||
-> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02
|
|
||||||
-> validDate="2027-01-02 04:00:00" ✅ 完全吻合
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
三、ticket.creditNum 从哪来
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
游戏本地主数据:
|
|
||||||
<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml
|
|
||||||
已抄进 TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
四、安全闸门
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
本接口会**真实写入一条购买记录**,所以默认不发包:
|
|
||||||
UpsertUserChargelog(...) -> 抛 ChargelogConfirmError
|
|
||||||
UpsertUserChargelog(..., dry_run=True) -> 只构造报文
|
|
||||||
UpsertUserChargelog(..., confirm=True) -> 真实发送
|
|
||||||
全局开关:config.UPSERT_CHARGELOG_CONFIRM(默认 False)
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
五、调用示例
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
from api import setup_logging, ParseQRCode, UserLogin, UserLogout,
|
|
||||||
LogoutType, UpsertUserChargelog
|
|
||||||
|
|
||||||
setup_logging("买票")
|
|
||||||
user_id, token = ParseQRCode(qr)
|
|
||||||
login = UserLogin(user_id, token)
|
|
||||||
UpsertUserChargelog(user_id, 2, login.loginDateTime, confirm=True)
|
|
||||||
UserLogout(user_id, token, login.loginDateTime, logout_type=LogoutType.TestIn)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Any, Dict, List, Optional, Union
|
|
||||||
|
|
||||||
import config
|
|
||||||
from config import (
|
|
||||||
CLIENT_ID,
|
|
||||||
PLACE_ID,
|
|
||||||
REGION_ID,
|
|
||||||
TIMEOUT_MS,
|
|
||||||
UPSERT_CHARGELOG_CONFIRM,
|
|
||||||
)
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 功能票(Ticket)主数据(原在 config.py,2026-10-06 下沉到本模块)
|
|
||||||
# =============================================================================
|
|
||||||
#
|
|
||||||
# 依据:游戏本地主数据
|
|
||||||
# Sinmai_Data/StreamingAssets/A000/ticket/ticketXXXXXX/Ticket.xml
|
|
||||||
# 源码侧 Manager.MaiStudio.TicketData.creditNum
|
|
||||||
#
|
|
||||||
# ★ 为什么需要它:
|
|
||||||
# UpsertUserChargelogApi(购买功能票)的 userChargelog.price 取的是
|
|
||||||
# **ticket.creditNum(票的信用点倍数)**,不是 GetGameChargeApi 里的 money price:
|
|
||||||
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:410
|
|
||||||
# result.price = ticket.creditNum;
|
|
||||||
#
|
|
||||||
# ticketKind 含义:1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
|
|
||||||
|
|
||||||
#: chargeId -> creditNum
|
|
||||||
TICKET_CREDIT_NUM = {
|
|
||||||
0: 0,
|
|
||||||
2: 1, 3: 2, 4: 3, 5: 4, 6: 5, # 付费票:N 倍券的 creditNum = N-1
|
|
||||||
10005: 0, 10105: 0, 10205: 0, # 活动票
|
|
||||||
11001: 0, # 免费 1.5 倍券
|
|
||||||
30001: 0, 30002: 0, 30003: 0, # 连动 / 商店票
|
|
||||||
}
|
|
||||||
|
|
||||||
#: 票名(只用于日志好看,不影响协议)
|
|
||||||
TICKET_NAMES = {
|
|
||||||
0: "使用しない",
|
|
||||||
2: "2倍チケット", 3: "3倍チケット", 4: "4倍チケット", 5: "5倍チケット", 6: "6倍チケット",
|
|
||||||
10005: "イベント5倍チケット_1", 10105: "イベント5倍チケット_2",
|
|
||||||
10205: "イベント5倍チケット_3", 11001: "無料1.5倍チケット",
|
|
||||||
30001: "連動チケット", 30002: "每周区域前进2倍票", 30003: "旅行伙伴等级提升5倍票",
|
|
||||||
}
|
|
||||||
|
|
||||||
#: 付费票(ticketKind=1)—— 只有这些能在机台购买
|
|
||||||
PAID_TICKET_IDS = (2, 3, 4, 5, 6)
|
|
||||||
|
|
||||||
#: 票有效期算法参数
|
|
||||||
#: 依据 source/Assembly-CSharp/Manager/TicketManager.cs:564
|
|
||||||
#: DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
|
||||||
#: 真机验证:chargeId=5 的 purchaseDate=2026-10-04 18:22:10
|
|
||||||
#: -> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02 -> "2027-01-02 04:00:00"
|
|
||||||
TICKET_VALID_HOUR_OFFSET = -4
|
|
||||||
TICKET_VALID_DAYS = 90
|
|
||||||
|
|
||||||
from .log_setup import log_debug, log_error, log_info, log_warning
|
|
||||||
from .maimai_net import MaimaiNetClient, request_dict
|
|
||||||
from .upsert_user_all import UpsertResponseVO, resolve_api_func
|
|
||||||
|
|
||||||
|
|
||||||
class ChargelogConfirmError(RuntimeError):
|
|
||||||
"""有副作用的接口被安全闸门拦下(此时**未发包**)。"""
|
|
||||||
|
|
||||||
|
|
||||||
class ChargelogGuardError(RuntimeError):
|
|
||||||
"""
|
|
||||||
安全检查未通过(此时**未发包**)。
|
|
||||||
|
|
||||||
★ 目前唯一的触发条件:**账号当前已经持有功能票**。
|
|
||||||
|
|
||||||
真机行为:`UseTicketPrepare` 只在「本局要用这张票」时才走购买分支,
|
|
||||||
而一张票买到手后会立刻被本局用掉(`RequestSuccess` 里 stock--)。
|
|
||||||
所以正常状态下 `userChargeList` 里的 stock 全是 0。
|
|
||||||
|
|
||||||
如果账号**已经持有**一张功能票还去发购买请求,
|
|
||||||
服务端看到的是「重复购买 / 状态矛盾」,有**封号风险**。
|
|
||||||
所以本模块默认硬拦。
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 一、VO(字段顺序照 source/Assembly-CSharp/Net.VO.Mai2/*.cs 声明)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserChargeVO:
|
|
||||||
"""GetUserCharge —— 一张票的持有状态(GetUserCharge.cs,4 字段)。"""
|
|
||||||
chargeId: int = 0
|
|
||||||
stock: int = 0
|
|
||||||
purchaseDate: str = ""
|
|
||||||
validDate: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserChargelogVO:
|
|
||||||
"""UserChargelog —— 一条购买记录(UserChargelog.cs,8 字段)。"""
|
|
||||||
chargeId: int = 0
|
|
||||||
price: int = 0
|
|
||||||
purchaseDate: str = ""
|
|
||||||
playCount: int = 0
|
|
||||||
playerRating: int = 0
|
|
||||||
placeId: int = 0
|
|
||||||
regionId: int = 0
|
|
||||||
clientId: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserChargelogRequestVO:
|
|
||||||
"""请求 VO(UserChargelogRequestVO.cs,4 字段,顺序照声明)。"""
|
|
||||||
userId: int
|
|
||||||
userChargelog: UserChargelogVO
|
|
||||||
userCharge: UserChargeVO
|
|
||||||
loginDateTime: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 二、工具
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def ticket_name(charge_id: int) -> str:
|
|
||||||
"""取票名(只用于日志)。"""
|
|
||||||
return TICKET_NAMES.get(int(charge_id), "未知票(" + str(charge_id) + ")")
|
|
||||||
|
|
||||||
|
|
||||||
def ticket_price(charge_id: int, override: Optional[int] = None) -> int:
|
|
||||||
"""
|
|
||||||
取 userChargelog.price —— 即 ticket.creditNum。
|
|
||||||
|
|
||||||
依据 VOExtensions.cs:410 result.price = ticket.creditNum;
|
|
||||||
数据来源 TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。
|
|
||||||
"""
|
|
||||||
if override is not None:
|
|
||||||
return int(override)
|
|
||||||
if int(charge_id) not in TICKET_CREDIT_NUM:
|
|
||||||
log_warning("TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id)
|
|
||||||
+ " —— 按 0 处理,建议补进配置")
|
|
||||||
return 0
|
|
||||||
return int(TICKET_CREDIT_NUM[int(charge_id)])
|
|
||||||
|
|
||||||
|
|
||||||
#: 北京时间(CST = UTC+8)
|
|
||||||
#: 依据 Manager/TimeManager.cs:9 CSToffset = new TimeSpan(8, 0, 0)
|
|
||||||
CST = timezone(timedelta(hours=8))
|
|
||||||
|
|
||||||
|
|
||||||
def now_cst(now: Optional[datetime] = None) -> datetime:
|
|
||||||
"""
|
|
||||||
取「北京时间现在」;传了 now 就用它(视作已是北京时间)。
|
|
||||||
|
|
||||||
★ 为什么要显式 +8 而不是 datetime.now():
|
|
||||||
TimeManager.GetNowTime() 返回 **DateTime.UtcNow**,
|
|
||||||
GetDateString() 再 Add(CSToffset) —— 即游戏里的日期串**恒是北京时间**,
|
|
||||||
与本机时区无关。脚本跑在非东八区机器上时 datetime.now() 会错。
|
|
||||||
"""
|
|
||||||
if now is not None:
|
|
||||||
return now
|
|
||||||
return datetime.now(timezone.utc).astimezone(CST)
|
|
||||||
|
|
||||||
|
|
||||||
def _now_string(now: Optional[datetime] = None) -> str:
|
|
||||||
"""
|
|
||||||
TimeManager.GetNowDateString() 的等价物。
|
|
||||||
|
|
||||||
依据 Manager/TimeManager.cs:7,62-70:
|
|
||||||
DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f"
|
|
||||||
GetDateString(unix) = GetDateTime(unix).ToString("yyyy-MM-dd HH:mm:ss.f")
|
|
||||||
|
|
||||||
★★ 注意末尾那个 **.f**(一位小数)—— 真机报文长这样:
|
|
||||||
"purchaseDate":"2026-10-06 13:20:10.0"
|
|
||||||
少写这个 ".0" 服务端会直接 returnCode=0(实测踩过)。
|
|
||||||
★ 而 validDate 用的是另一套格式(见 calc_valid_date),**不带**小数。
|
|
||||||
"""
|
|
||||||
d = now_cst(now)
|
|
||||||
return d.strftime("%Y-%m-%d %H:%M:%S") + "." + str(d.microsecond // 100000)
|
|
||||||
|
|
||||||
|
|
||||||
def calc_valid_date(
|
|
||||||
now: Optional[datetime] = None,
|
|
||||||
*,
|
|
||||||
hour_offset: int = TICKET_VALID_HOUR_OFFSET,
|
|
||||||
days: int = TICKET_VALID_DAYS,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
算票的有效期字符串。
|
|
||||||
|
|
||||||
依据 TicketManager.cs:562-565:
|
|
||||||
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
|
||||||
|
|
||||||
★ 注意这里用的是**显式格式串**(没有 .f),所以结果形如
|
|
||||||
"2027-01-04 04:00:00" —— 与 purchaseDate 的 "…10.0" 不同。
|
|
||||||
"""
|
|
||||||
base = now_cst(now)
|
|
||||||
d = (base + timedelta(hours=hour_offset) + timedelta(days=days)).date()
|
|
||||||
return d.strftime("%Y-%m-%d") + " 04:00:00"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_wall_time(text: Any) -> Optional[datetime]:
|
|
||||||
"""
|
|
||||||
解析墙上时间串,兼容两种格式:
|
|
||||||
|
|
||||||
"2026-10-06 13:20:10.0" ← TimeManager.DateTimeFormat(一位小数,真机报文用这个)
|
|
||||||
"2026-10-06 13:20:10" ← 无小数(服务端回给 GetUserChargeApi 的是这个)
|
|
||||||
|
|
||||||
解析不出来返回 None。
|
|
||||||
"""
|
|
||||||
s = str(text or "").strip()
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
|
|
||||||
try:
|
|
||||||
return datetime.strptime(s, fmt)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _find_charge(charge_list: Any, charge_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
"""在 GetUserChargeApi 的 userChargeList 里找指定 chargeId。"""
|
|
||||||
if not isinstance(charge_list, list):
|
|
||||||
return None
|
|
||||||
for c in charge_list:
|
|
||||||
if isinstance(c, dict) and int(c.get("chargeId", -1) or -1) == int(charge_id):
|
|
||||||
return c
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 三、主函数
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def UpsertUserChargelog(
|
|
||||||
user_id: int,
|
|
||||||
charge_id: int,
|
|
||||||
login_date_time: int,
|
|
||||||
*,
|
|
||||||
charge_list: Optional[List[Dict[str, Any]]] = None,
|
|
||||||
game_charge_list: Optional[List[Dict[str, Any]]] = None,
|
|
||||||
user_data: Optional[Dict[str, Any]] = None,
|
|
||||||
stock: Optional[int] = None,
|
|
||||||
price: Optional[int] = None,
|
|
||||||
purchase_date: Optional[str] = None,
|
|
||||||
valid_date: Optional[str] = None,
|
|
||||||
play_count: Optional[int] = None,
|
|
||||||
player_rating: Optional[int] = None,
|
|
||||||
place_id: int = PLACE_ID,
|
|
||||||
region_id: int = REGION_ID,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
guard_no_stock: bool = True,
|
|
||||||
guard_purchasable: bool = True,
|
|
||||||
confirm: Optional[bool] = None,
|
|
||||||
dry_run: bool = False,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Union[UserChargelogRequestVO, UpsertResponseVO]:
|
|
||||||
"""
|
|
||||||
购买一张功能票(UpsertUserChargelogApi,★ 有副作用)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
user_id: 用户 ID(必填)
|
|
||||||
charge_id: 要买的票 ID(必填,= GetGameChargeApi 里的 chargeId)
|
|
||||||
★ 付费票是 2/3/4/5/6(PAID_TICKET_IDS)
|
|
||||||
login_date_time: 登录时刻(必填,★ 必须 == UserLoginApi 的 loginDateTime)
|
|
||||||
charge_list: 当前票券列表(可选)。不传则自动调 GetUserChargeApi。
|
|
||||||
用来算「购买后库存」和是否已有该票。
|
|
||||||
user_data: 当前 userData(可选)。不传则自动调 GetUserDataApi。
|
|
||||||
用来填 playCount / playerRating。
|
|
||||||
stock: ★ 购买后该票的库存;不传 = 现有 + 1
|
|
||||||
price: ★ userChargelog.price;不传 = TICKET_CREDIT_NUM[charge_id]
|
|
||||||
purchase_date: 购买时刻字符串;不传 = 现在
|
|
||||||
valid_date: 有效期字符串;不传 = 现在-4h+90d(见 calc_valid_date)
|
|
||||||
play_count: 覆盖 playCount(不传则取 userData.playCount)
|
|
||||||
player_rating: 覆盖 playerRating(不传则取 userData.playerRating)
|
|
||||||
guard_purchasable: ★ 默认 True = **只允许买 GetGameChargeApi 列出来的票**
|
|
||||||
(不在清单里就抛 ChargelogGuardError,未发包)。
|
|
||||||
依据 TicketManager.cs:137-150 —— 真机也是先在
|
|
||||||
GetGameChargeDataList() 里找到该 chargeId 才发购买请求,
|
|
||||||
找不到直接 return false。服务端对未上架的票会 returnCode=0。
|
|
||||||
实测:本机台只上架 chargeId=2/3,买 5 会失败。
|
|
||||||
game_charge_list: 可购买清单(可选)。不传则自动调 GetGameChargeApi。
|
|
||||||
guard_no_stock: ★ 默认 True = **账号当前若已持有任何功能票就直接拒绝购买**
|
|
||||||
(抛 ChargelogGuardError,未发包)。
|
|
||||||
理由见 ChargelogGuardError 的 docstring —— 有封号风险。
|
|
||||||
确认要强行购买才传 False。
|
|
||||||
confirm: 副作用闸门;None 时取 config.UPSERT_CHARGELOG_CONFIRM
|
|
||||||
dry_run: True = 只构造并返回报文,绝不发包
|
|
||||||
|
|
||||||
返回:
|
|
||||||
dry_run=True -> UserChargelogRequestVO
|
|
||||||
dry_run=False -> UpsertResponseVO
|
|
||||||
"""
|
|
||||||
if not login_date_time:
|
|
||||||
raise ValueError("缺少 login_date_time:必须传 UserLoginApi 的 loginDateTime"
|
|
||||||
"(PacketUploadUserChargelog.cs:23)")
|
|
||||||
|
|
||||||
name = ticket_name(charge_id)
|
|
||||||
log_info("购买功能票:user_id=" + str(user_id) + " chargeId=" + str(charge_id)
|
|
||||||
+ "(" + name + ")dry_run=" + str(dry_run))
|
|
||||||
|
|
||||||
if int(charge_id) not in tuple(PAID_TICKET_IDS):
|
|
||||||
log_warning("chargeId=" + str(charge_id) + " 不在 PAID_TICKET_IDS="
|
|
||||||
+ str(tuple(PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票")
|
|
||||||
|
|
||||||
# ---------- 1. 取当前状态 ----------
|
|
||||||
if charge_list is None:
|
|
||||||
log_info("未传 charge_list,自动抓取当前票券 ...")
|
|
||||||
raw = resolve_api_func("GetUserCharge")(
|
|
||||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
charge_list = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
|
|
||||||
|
|
||||||
if user_data is None:
|
|
||||||
log_info("未传 user_data,自动抓取当前档案 ...")
|
|
||||||
raw_ud = resolve_api_func("GetUserData")(
|
|
||||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
user_data = (raw_ud.get("userData") if isinstance(raw_ud, dict) else None) or {}
|
|
||||||
|
|
||||||
# ---------- 1.4 ★ 安全检查:这张票当前**上架了吗** ----------
|
|
||||||
# 依据 TicketManager.cs:137-150:真机先在 GetGameChargeDataList() 里找该
|
|
||||||
# chargeId,找到才 RequestUserCharge,找不到直接 return false(不买)。
|
|
||||||
# 服务端对未上架的票会 returnCode=0 —— 实测买 chargeId=5 就是这种情况。
|
|
||||||
if guard_purchasable:
|
|
||||||
if game_charge_list is None:
|
|
||||||
log_info("未传 game_charge_list,自动抓取可购买清单 ...")
|
|
||||||
_gc = resolve_api_func("GetGameCharge")(
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
game_charge_list = (_gc.get("gameChargeList")
|
|
||||||
if isinstance(_gc, dict) else None) or []
|
|
||||||
avail = [int(g.get("chargeId")) for g in (game_charge_list or [])
|
|
||||||
if isinstance(g, dict) and g.get("chargeId") is not None]
|
|
||||||
log_info("当前可购买 chargeId:" + str(avail))
|
|
||||||
if int(charge_id) not in avail:
|
|
||||||
msg = ("安全检查未通过:chargeId=" + str(charge_id) + "(" + name
|
|
||||||
+ ")**不在当前可购买清单里**。\n"
|
|
||||||
" 可购买:" + (str(avail) if avail else "(空)") + "\n"
|
|
||||||
" 依据 TicketManager.cs:137-150 —— 真机也只买 GetGameChargeApi\n"
|
|
||||||
" 列出来的票,找不到就不发购买请求。\n"
|
|
||||||
" 服务端对未上架的票会 returnCode=0(实测过)。\n"
|
|
||||||
" 确需强行购买请传 guard_purchasable=False。")
|
|
||||||
log_error(msg)
|
|
||||||
raise ChargelogGuardError(msg)
|
|
||||||
log_info("安全检查通过:chargeId=" + str(charge_id) + " 在可购买清单里")
|
|
||||||
|
|
||||||
# ---------- 1.5 ★ 安全检查:账号当前是否已持有功能票 ----------
|
|
||||||
held = [c for c in (charge_list or [])
|
|
||||||
if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
|
|
||||||
if held:
|
|
||||||
desc = "、".join(
|
|
||||||
"chargeId=" + str(c.get("chargeId")) + "(" + ticket_name(c.get("chargeId"))
|
|
||||||
+ ") stock=" + str(c.get("stock")) + " validDate=" + str(c.get("validDate"))
|
|
||||||
for c in held)
|
|
||||||
log_warning("★ 账号当前已持有 " + str(len(held)) + " 张功能票:" + desc)
|
|
||||||
if guard_no_stock:
|
|
||||||
msg = ("安全检查未通过:账号当前**已持有功能票**(" + desc + "),拒绝再次购买。\n"
|
|
||||||
" 原因:真机只在「本局要用这张票」时才购买,且买到手会立刻用掉"
|
|
||||||
"(stock--),正常状态 stock 恒为 0。\n"
|
|
||||||
" 已经持票还发购买请求,服务端看到的是重复购买,有**封号风险**。\n"
|
|
||||||
" 确需强行购买请传 guard_no_stock=False。")
|
|
||||||
log_error(msg)
|
|
||||||
raise ChargelogGuardError(msg)
|
|
||||||
log_warning("guard_no_stock=False —— 已跳过该检查,继续购买")
|
|
||||||
else:
|
|
||||||
log_info("安全检查通过:账号当前没有持有任何功能票")
|
|
||||||
|
|
||||||
existing = _find_charge(charge_list, charge_id)
|
|
||||||
old_stock = int(existing.get("stock", 0) or 0) if existing else 0
|
|
||||||
if stock is None:
|
|
||||||
stock = old_stock + 1
|
|
||||||
stock = int(stock)
|
|
||||||
|
|
||||||
if price is None:
|
|
||||||
price = ticket_price(charge_id)
|
|
||||||
price = int(price)
|
|
||||||
|
|
||||||
if play_count is None:
|
|
||||||
play_count = int(user_data.get("playCount", 0) or 0)
|
|
||||||
if player_rating is None:
|
|
||||||
player_rating = int(user_data.get("playerRating", 0) or 0)
|
|
||||||
|
|
||||||
now = now_cst()
|
|
||||||
if not purchase_date:
|
|
||||||
purchase_date = _now_string(now)
|
|
||||||
if not valid_date:
|
|
||||||
# ★ 真机里 purchaseDate 与 validDate 都来自同一个 DateTime.Now
|
|
||||||
# (TicketManager.cs:216-219 与 :564),所以调用方若显式给了 purchaseDate,
|
|
||||||
# 有效期就该从**那个时刻**推,而不是从现在推。
|
|
||||||
base = parse_wall_time(purchase_date)
|
|
||||||
if base is None:
|
|
||||||
log_warning("purchase_date 解析不了(应为 'YYYY-MM-DD HH:MM:SS[.f]'),"
|
|
||||||
"validDate 改按「现在」计算:" + str(purchase_date))
|
|
||||||
base = now
|
|
||||||
valid_date = calc_valid_date(base)
|
|
||||||
|
|
||||||
log_info(" 票名 : " + name)
|
|
||||||
log_info(" 库存 : " + str(old_stock) + " -> " + str(stock))
|
|
||||||
log_info(" price : " + str(price) + "(= ticket.creditNum)")
|
|
||||||
log_info(" purchaseDate: " + purchase_date)
|
|
||||||
log_info(" validDate : " + valid_date)
|
|
||||||
|
|
||||||
# ---------- 2. 构造请求 ----------
|
|
||||||
req = UserChargelogRequestVO(
|
|
||||||
userId=int(user_id),
|
|
||||||
userChargelog=UserChargelogVO(
|
|
||||||
chargeId=int(charge_id),
|
|
||||||
price=price,
|
|
||||||
purchaseDate=purchase_date,
|
|
||||||
playCount=int(play_count),
|
|
||||||
playerRating=int(player_rating),
|
|
||||||
placeId=int(place_id),
|
|
||||||
regionId=int(region_id),
|
|
||||||
clientId=client_id,
|
|
||||||
),
|
|
||||||
userCharge=UserChargeVO(
|
|
||||||
chargeId=int(charge_id),
|
|
||||||
stock=stock,
|
|
||||||
purchaseDate=purchase_date,
|
|
||||||
validDate=valid_date,
|
|
||||||
),
|
|
||||||
loginDateTime=int(login_date_time),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------- 3. dry-run ----------
|
|
||||||
if dry_run:
|
|
||||||
log_info("[dry-run] 已构造购买报文,未发包")
|
|
||||||
log_debug("dry-run 报文:" + __import__("json").dumps(
|
|
||||||
req, ensure_ascii=False, default=str))
|
|
||||||
return req
|
|
||||||
|
|
||||||
# ---------- 4. 副作用闸门 ----------
|
|
||||||
effective = UPSERT_CHARGELOG_CONFIRM if confirm is None else bool(confirm)
|
|
||||||
if not effective:
|
|
||||||
msg = ("UpsertUserChargelogApi 会**真实写入一条购买记录**(不可撤销)。"
|
|
||||||
"本脚本默认不放行。确认要发请传 confirm=True,"
|
|
||||||
"或改 config.UPSERT_CHARGELOG_CONFIRM = True。"
|
|
||||||
"只想看报文请传 dry_run=True。")
|
|
||||||
log_error(msg)
|
|
||||||
raise ChargelogConfirmError(msg)
|
|
||||||
|
|
||||||
# ---------- 5. 发包 ----------
|
|
||||||
log_info("闸门已放行,开始上传购买记录 ...")
|
|
||||||
raw = request_dict(
|
|
||||||
"UpsertUserChargelogApi", req,
|
|
||||||
user_id=user_id, client_id=client_id,
|
|
||||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
|
||||||
)
|
|
||||||
resp = UpsertResponseVO(
|
|
||||||
returnCode=int(raw.get("returnCode", 0) or 0),
|
|
||||||
apiName=str(raw.get("apiName", "") or ""),
|
|
||||||
)
|
|
||||||
if resp.returnCode != 1:
|
|
||||||
msg = ("购买失败:returnCode=" + str(resp.returnCode)
|
|
||||||
+ " apiName=" + resp.apiName)
|
|
||||||
log_error(msg)
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
|
|
||||||
log_info("购买成功:returnCode=" + str(resp.returnCode) + " apiName=" + resp.apiName)
|
|
||||||
log_info(" 票名 " + name + " 库存 " + str(old_stock) + " -> " + str(stock))
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 四、单独运行(调试)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
from .log_setup import setup_logging, teardown_logging
|
|
||||||
|
|
||||||
def _main() -> int:
|
|
||||||
setup_logging("chargelog_test")
|
|
||||||
try:
|
|
||||||
log_info("构造一张 2 倍券的购买报文(不发包)")
|
|
||||||
req = UpsertUserChargelog(
|
|
||||||
12345678, 2, 1791159576,
|
|
||||||
charge_list=[], user_data={"playCount": 1260, "playerRating": 15000},
|
|
||||||
dry_run=True,
|
|
||||||
)
|
|
||||||
log_info("userChargelog = " + str(req.userChargelog))
|
|
||||||
log_info("userCharge = " + str(req.userCharge))
|
|
||||||
log_info("validDate 算法自检:")
|
|
||||||
log_info(" 2026-10-04 18:22:10 买 5 倍券 -> "
|
|
||||||
+ calc_valid_date(datetime(2026, 10, 4, 18, 22, 10))
|
|
||||||
+ "(真机是 2027-01-02 04:00:00)")
|
|
||||||
return 0
|
|
||||||
finally:
|
|
||||||
teardown_logging()
|
|
||||||
|
|
||||||
raise SystemExit(_main())
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
user_getter_common.py —— 各取数脚本的公共底座
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
只提供三样东西,取数逻辑都在各自的脚本里:
|
|
||||||
|
|
||||||
1. 各接口的请求 VO(严格对应 source/.../Net.VO.Mai2/*RequestVO.cs)
|
|
||||||
2. _get() 发一次请求并返回**原始响应 dict**
|
|
||||||
3. get_all_pages() 自动翻页
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
★ 为什么单独抽出来:各接口的请求体并不统一
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
权威依据 source/Assembly-CSharp/Net.VO.Mai2/ 下各自的 XxxRequestVO.cs:
|
|
||||||
|
|
||||||
GameKaleidxScopeRequestVO : 空类,一个字段都没有
|
|
||||||
UserDetailRequestVO : 只有 userId
|
|
||||||
UserMapRequestVO : userId + nextIndex + maxCount = 1000
|
|
||||||
UserLoginBonusRequestVO : userId + nextIndex + maxCount = 20
|
|
||||||
UserItemRequestVO : userId + nextIndex + maxCount = 100
|
|
||||||
UserMusicRequestVO : userId + nextIndex + maxCount = 50
|
|
||||||
UserFavoriteRequestVO : userId + itemKind
|
|
||||||
UserFavoriteItemRequestVO : userId + kind + nextIndex + maxCount = 100 + isAllFavoriteItem
|
|
||||||
UserShopStockRequestVO : userId + shopItemIdList
|
|
||||||
UserRivalMusicRequestVO : userId + rivalId + nextIndex + userRivalMusicLevelList
|
|
||||||
|
|
||||||
发错字段名或漏字段,服务端会返回异常或空数据。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_debug, log_warning
|
|
||||||
from .maimai_net import MaimaiNetClient, get_default_client
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 请求 VO(逐字段对应 C# 的 XxxRequestVO)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EmptyRequestVO:
|
|
||||||
"""空请求体(对应 GameKaleidxScopeRequestVO —— 它确实没有任何字段)。"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserIdRequestVO:
|
|
||||||
"""只有 userId(对应 UserDetailRequestVO / UserCharacterRequestVO / UserOptionRequestVO 等)。"""
|
|
||||||
userId: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserIdPageRequestVO:
|
|
||||||
"""通用分页(对应 UserMapRequestVO,maxCount 默认 1000)。"""
|
|
||||||
userId: int
|
|
||||||
nextIndex: int = 0
|
|
||||||
maxCount: int = 1000
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserLoginBonusRequestVO:
|
|
||||||
"""对应 UserLoginBonusRequestVO(maxCount 默认 20)。"""
|
|
||||||
userId: int
|
|
||||||
nextIndex: int = 0
|
|
||||||
maxCount: int = 20
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserItemRequestVO:
|
|
||||||
"""对应 UserItemRequestVO(maxCount 默认 100)。"""
|
|
||||||
userId: int
|
|
||||||
nextIndex: int = 0
|
|
||||||
maxCount: int = 100
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserMusicRequestVO:
|
|
||||||
"""对应 UserMusicRequestVO(maxCount 默认 50)。"""
|
|
||||||
userId: int
|
|
||||||
nextIndex: int = 0
|
|
||||||
maxCount: int = 50
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserCourseRequestVO:
|
|
||||||
"""对应 UserCourseRequestVO(只有 userId + nextIndex,**没有** maxCount)。"""
|
|
||||||
userId: int
|
|
||||||
nextIndex: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserFavoriteRequestVO:
|
|
||||||
"""对应 UserFavoriteRequestVO(按 itemKind 分次请求)。"""
|
|
||||||
userId: int
|
|
||||||
itemKind: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserFavoriteItemRequestVO:
|
|
||||||
"""对应 UserFavoriteItemRequestVO(注意字段名是 kind,不是 itemKind)。"""
|
|
||||||
userId: int
|
|
||||||
kind: int = 1
|
|
||||||
nextIndex: int = 0
|
|
||||||
maxCount: int = 100
|
|
||||||
isAllFavoriteItem: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserShopStockRequestVO:
|
|
||||||
"""对应 UserShopStockRequestVO。"""
|
|
||||||
userId: int
|
|
||||||
shopItemIdList: List[int] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserRivalRequestVO:
|
|
||||||
"""对应 UserRivalRequestVO。"""
|
|
||||||
userId: int
|
|
||||||
rivalId: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserRivalMusicRequestVO:
|
|
||||||
"""对应 UserRivalMusicRequestVO(level 列表固定 6 档)。"""
|
|
||||||
userId: int
|
|
||||||
rivalId: int = 0
|
|
||||||
nextIndex: int = 0
|
|
||||||
userRivalMusicLevelList: List[Dict[str, int]] = field(
|
|
||||||
default_factory=lambda: [{"level": lv} for lv in (0, 1, 2, 3, 4, 10)]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 常量
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
#: GetUserItemApi 的 itemKind 取值(对应 Net.VO.Mai2/ItemKind.cs)
|
|
||||||
#: 客户端在 StateUserDownload.cs 里依次下载这些 kind
|
|
||||||
ITEM_KINDS: tuple = (1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 14)
|
|
||||||
|
|
||||||
#: ★ GetUserItemApi 首页 nextIndex 的哨兵系数。
|
|
||||||
#: 依据 source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
|
|
||||||
#: Create(GetNetQuery(userId, itemId * 10000000000L));
|
|
||||||
#: 传 0 会被服务端拒绝。真机日志 source/2026-10-05.log:32 也是 10000000000。
|
|
||||||
ITEM_NEXT_INDEX_FACTOR: int = 10000000000
|
|
||||||
|
|
||||||
#: GetUserFavoriteApi 的 itemKind(真机顺序固定 3/1/2/10/11,见 source/2026-10-05.log:59-68)
|
|
||||||
FAVORITE_ITEM_KINDS: tuple = (3, 1, 2, 10, 11)
|
|
||||||
|
|
||||||
#: GetUserRivalMusicApi 的 level 档位(真机 source/2026-10-05.log:101)
|
|
||||||
RIVAL_MUSIC_LEVELS: tuple = (0, 1, 2, 3, 4, 10)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 内部工具
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def _get(
|
|
||||||
api_name: str,
|
|
||||||
body: Any,
|
|
||||||
user_id: int,
|
|
||||||
*,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
发一次请求,返回**原始响应 dict**(不做 VO 映射,保留服务端全部字段)。
|
|
||||||
|
|
||||||
★ 取数脚本一律走这里:upsertUserAll 需要响应原文,
|
|
||||||
映射成 dataclass 会丢掉服务端多给的字段。
|
|
||||||
"""
|
|
||||||
net = client or get_default_client()
|
|
||||||
raw = net.request(
|
|
||||||
api_name, body,
|
|
||||||
user_id=user_id, client_id=client_id,
|
|
||||||
timeout_ms=timeout_ms or TIMEOUT_MS,
|
|
||||||
)
|
|
||||||
if isinstance(raw, str):
|
|
||||||
return json.loads(raw) if raw else {}
|
|
||||||
return raw or {}
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_pages(
|
|
||||||
api_name: str,
|
|
||||||
user_id: int,
|
|
||||||
list_key: str,
|
|
||||||
*,
|
|
||||||
body_factory: Optional[Callable[[Optional[int]], Any]] = None,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
max_pages: int = 200,
|
|
||||||
) -> List[Any]:
|
|
||||||
"""
|
|
||||||
自动翻页抓取某个列表接口的全部条目。
|
|
||||||
|
|
||||||
分页约定(各 Packet 的 Proc 里都是这个逻辑):
|
|
||||||
nextIndex == 0 -> 没有更多数据
|
|
||||||
nextIndex != 0 -> 下一页的起始索引
|
|
||||||
|
|
||||||
参数:
|
|
||||||
api_name: 逻辑接口名
|
|
||||||
list_key: 响应里承载列表的键名(如 "userMapList")
|
|
||||||
body_factory: 可选;接收 next_index 返回请求 VO 的工厂。
|
|
||||||
★ 首页会以 next_index=None 调用它 —— 这是为了支持
|
|
||||||
GetUserItemApi 这类「首发要带哨兵值」的接口
|
|
||||||
(首包 nextIndex = itemKind * 1e10,不能是 0)。
|
|
||||||
max_pages: 翻页上限(防御性)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
合并后的全部条目
|
|
||||||
"""
|
|
||||||
all_items: List[Any] = []
|
|
||||||
next_index: Optional[int] = None
|
|
||||||
for page in range(max_pages):
|
|
||||||
if body_factory is not None:
|
|
||||||
body = body_factory(next_index)
|
|
||||||
else:
|
|
||||||
body = UserIdPageRequestVO(userId=user_id, nextIndex=next_index or 0)
|
|
||||||
|
|
||||||
resp = _get(api_name, body, user_id,
|
|
||||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
|
||||||
items = resp.get(list_key) or []
|
|
||||||
if isinstance(items, dict):
|
|
||||||
items = [items]
|
|
||||||
all_items.extend(items)
|
|
||||||
|
|
||||||
next_index = int(resp.get("nextIndex") or 0)
|
|
||||||
log_debug(
|
|
||||||
api_name + " 第 " + str(page + 1) + " 页:本页 " + str(len(items))
|
|
||||||
+ " 条,累计 " + str(len(all_items)) + " 条,nextIndex=" + str(next_index)
|
|
||||||
)
|
|
||||||
if not next_index:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
log_warning(api_name + " 超过 " + str(max_pages) + " 页仍未结束,已中止翻页")
|
|
||||||
|
|
||||||
return all_items
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
userlogin.py —— 用户正式登录(UserLoginApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
功能:创建登录会话,返回 loginId / loginDateTime / 登录天数统计。
|
|
||||||
loginId 与 loginDateTime 是后续结算上报与登出的**必需输入**。
|
|
||||||
|
|
||||||
对应接口:UserLoginApi
|
|
||||||
请求体 :10 个字段,见 source/Assembly-CSharp/Net.VO.Mai2/UserLoginRequestVO.cs
|
|
||||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogin.cs:21-31
|
|
||||||
响应 :UserLoginResponseVO(returnCode=1 表示成功)
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
★ 关于 loginDateTime(与后续接口强相关,务必理解)
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
真机取的是「本地墙上时间被当作 UTC 时刻」的秒数,即
|
|
||||||
|
|
||||||
loginDateTime = 标准 Unix 时间戳 + 本地时区偏移
|
|
||||||
|
|
||||||
东八区即 now + 28800。
|
|
||||||
|
|
||||||
成因:MAI2.DateTime.cs 里 DateTime.UtcNow + LocalOffset 得到 Kind=Unspecified
|
|
||||||
的值,PacketUserLogin.cs:29 再用 new DateTimeOffset(该值) 又加一次本地偏移。
|
|
||||||
|
|
||||||
影响:PacketUserLogin.cs:43 会把请求值回填响应;
|
|
||||||
PacketUpsertUserAll.cs:31 / PacketUserLogout.cs:29 都复用该值,
|
|
||||||
与登录时不一致会被服务端拒绝。
|
|
||||||
|
|
||||||
本模块默认按真机算法生成;需要手动指定时传 login_date_time。
|
|
||||||
|
|
||||||
★ 关于 dateTime 与 loginDateTime 的区别
|
|
||||||
dateTime = ALL.Net 认证时刻(Auth.AuthTime,标准 Unix 秒)
|
|
||||||
loginDateTime= 本次登录时刻(上面的特殊算法)
|
|
||||||
两者都出现在 UserLoginRequestVO 里,但只有 loginDateTime 会被后续接口复用。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import UserLogin
|
|
||||||
login = UserLogin(12345678, "TESTTOKEN0000000000", region_id=1, place_id=1000)
|
|
||||||
print(login.loginId, login.loginDateTime)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_LONG_MS
|
|
||||||
|
|
||||||
from .log_setup import log_debug, log_error, log_info
|
|
||||||
from .maimai_net import (
|
|
||||||
MaimaiNetClient,
|
|
||||||
local_utc_offset_seconds,
|
|
||||||
login_date_time_now,
|
|
||||||
request_dict,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserLoginRequestVO:
|
|
||||||
"""请求 VO(对应 UserLoginRequestVO,字段顺序照 C# 声明)。"""
|
|
||||||
userId: int
|
|
||||||
accessCode: str = ""
|
|
||||||
regionId: int = 0
|
|
||||||
placeId: int = 0
|
|
||||||
clientId: str = ""
|
|
||||||
dateTime: int = 0
|
|
||||||
loginDateTime: int = 0
|
|
||||||
isContinue: bool = False
|
|
||||||
genericFlag: int = 0
|
|
||||||
token: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserLoginResponseVO:
|
|
||||||
"""
|
|
||||||
响应 VO(对应 UserLoginResponseVO)。
|
|
||||||
|
|
||||||
★ 后两个字段是**客户端回填**的,服务端不返回:
|
|
||||||
loginDateTime —— PacketUserLogin.cs:43 用请求值回填,
|
|
||||||
UpsertUserAll / UserLogout 都复用它
|
|
||||||
dateTime —— 本次请求发出的 ALL.Net 认证时刻,
|
|
||||||
UpsertUserAll 的 userData.dateTime 要用它
|
|
||||||
(真机 source/2026-10-05.log:123 两处都是 1791134329)
|
|
||||||
"""
|
|
||||||
returnCode: int = 0
|
|
||||||
lastLoginDate: str = ""
|
|
||||||
loginCount: int = 0
|
|
||||||
consecutiveLoginCount: int = 0
|
|
||||||
loginId: int = 0
|
|
||||||
loginDateTime: int = 0
|
|
||||||
token: str = ""
|
|
||||||
dateTime: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
def UserLogin(
|
|
||||||
user_id: int,
|
|
||||||
token: str,
|
|
||||||
*,
|
|
||||||
access_code: str = "",
|
|
||||||
region_id: int = REGION_ID,
|
|
||||||
place_id: int = PLACE_ID,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
is_continue: bool = False,
|
|
||||||
generic_flag: int = 0,
|
|
||||||
date_time: Optional[int] = None,
|
|
||||||
login_date_time: Optional[int] = None,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> UserLoginResponseVO:
|
|
||||||
"""
|
|
||||||
用户正式登录(创建登录会话)。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
user_id: 用户 ID(必填)
|
|
||||||
token: 会话令牌(必填,来自 ParseQRCode)
|
|
||||||
access_code: 访问码(可选,通常为空)
|
|
||||||
region_id: 区域 ID(可选,默认 config.REGION_ID)
|
|
||||||
place_id: 店铺 ID(可选,默认 config.PLACE_ID)
|
|
||||||
client_id: 机台短值(可选)
|
|
||||||
is_continue: 是否续玩(可选)
|
|
||||||
generic_flag: 通用标志位(可选)
|
|
||||||
date_time: ALL.Net 认证时刻(可选,默认当前 Unix 秒)
|
|
||||||
login_date_time: 本次登录时刻(可选,默认按真机算法生成)
|
|
||||||
timeout_ms: 超时毫秒(可选,默认 60 秒)
|
|
||||||
client: 可复用的 MaimaiNetClient(可选)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
UserLoginResponseVO(loginDateTime 已按真机行为回填)
|
|
||||||
|
|
||||||
异常:
|
|
||||||
RuntimeError: returnCode != 1
|
|
||||||
"""
|
|
||||||
log_info(
|
|
||||||
"用户正式登录:user_id=" + str(user_id)
|
|
||||||
+ " region_id=" + str(region_id) + " place_id=" + str(place_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
now = int(time.time())
|
|
||||||
if date_time is None:
|
|
||||||
date_time = now
|
|
||||||
if login_date_time is None:
|
|
||||||
login_date_time = login_date_time_now()
|
|
||||||
|
|
||||||
log_debug(
|
|
||||||
"时间戳:dateTime=" + str(date_time) + " loginDateTime=" + str(login_date_time)
|
|
||||||
+ "(本地时区偏移 " + str(local_utc_offset_seconds()) + " 秒)"
|
|
||||||
)
|
|
||||||
|
|
||||||
raw = request_dict(
|
|
||||||
"UserLoginApi",
|
|
||||||
UserLoginRequestVO(
|
|
||||||
userId=user_id,
|
|
||||||
accessCode=access_code,
|
|
||||||
regionId=region_id,
|
|
||||||
placeId=place_id,
|
|
||||||
clientId=client_id,
|
|
||||||
dateTime=date_time,
|
|
||||||
loginDateTime=login_date_time,
|
|
||||||
isContinue=is_continue,
|
|
||||||
genericFlag=generic_flag,
|
|
||||||
token=token,
|
|
||||||
),
|
|
||||||
user_id=user_id, client_id=client_id,
|
|
||||||
timeout_ms=timeout_ms or TIMEOUT_LONG_MS, client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
known = {f for f in UserLoginResponseVO.__dataclass_fields__}
|
|
||||||
resp = UserLoginResponseVO(**{k: v for k, v in raw.items() if k in known})
|
|
||||||
|
|
||||||
if resp.returnCode != 1:
|
|
||||||
msg = "登录失败:returnCode=" + str(resp.returnCode)
|
|
||||||
log_error(msg)
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
|
|
||||||
# ★ 回填 loginDateTime(对应 PacketUserLogin.cs:43)
|
|
||||||
# 服务端不返回该字段,客户端用请求值回填;后续 UpsertUserAll / UserLogout 复用。
|
|
||||||
if not resp.loginDateTime:
|
|
||||||
resp.loginDateTime = login_date_time
|
|
||||||
log_debug("服务端未返回 loginDateTime,按真机行为回填为 " + str(login_date_time))
|
|
||||||
|
|
||||||
# ★ 回填 dateTime:本次请求实际发出的 ALL.Net 认证时刻。
|
|
||||||
# UpsertUserAll 的 userData.dateTime 要用它(真机两处一致)。
|
|
||||||
resp.dateTime = date_time
|
|
||||||
|
|
||||||
log_info(
|
|
||||||
"用户登录成功:loginId=" + str(resp.loginId)
|
|
||||||
+ " loginDateTime=" + str(resp.loginDateTime)
|
|
||||||
+ " dateTime=" + str(resp.dateTime)
|
|
||||||
+ " lastLoginDate=" + resp.lastLoginDate
|
|
||||||
+ " loginCount=" + str(resp.loginCount)
|
|
||||||
+ " consecutiveLoginCount=" + str(resp.consecutiveLoginCount)
|
|
||||||
)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from .log_setup import setup_logging, teardown_logging
|
|
||||||
|
|
||||||
def _main() -> int:
|
|
||||||
setup_logging("userlogin_test")
|
|
||||||
try:
|
|
||||||
if len(sys.argv) < 3:
|
|
||||||
print("用法: python -m api.userlogin <user_id> <token> [region_id] [place_id]")
|
|
||||||
return 1
|
|
||||||
rid = int(sys.argv[3]) if len(sys.argv) > 3 else REGION_ID
|
|
||||||
pid = int(sys.argv[4]) if len(sys.argv) > 4 else PLACE_ID
|
|
||||||
lg = UserLogin(int(sys.argv[1]), sys.argv[2], region_id=rid, place_id=pid)
|
|
||||||
print("")
|
|
||||||
for name in UserLoginResponseVO.__dataclass_fields__:
|
|
||||||
print(" " + name.ljust(24) + "= " + str(getattr(lg, name)))
|
|
||||||
return 0
|
|
||||||
except Exception as exc:
|
|
||||||
log_error("用户登录失败:" + str(exc))
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
teardown_logging()
|
|
||||||
|
|
||||||
raise SystemExit(_main())
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
userlogout.py —— 用户登出(UserLogoutApi)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
功能:结束登录会话,可选上报本次会话的网络统计(delayLog)。
|
|
||||||
|
|
||||||
对应接口:UserLogoutApi
|
|
||||||
请求体 :两种形态,见 source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogout.cs:22-63
|
|
||||||
type == 1 (Logout) -> UserLogoutRequest2VO(含 delayLog)
|
|
||||||
其他 type -> UserLogoutRequestVO (不含 delayLog)
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
登出类型(source/Assembly-CSharp/Net.VO.Mai2/LogoutType.cs)
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
None = 0
|
|
||||||
Logout = 1 正常登出(带 delayLog 统计)
|
|
||||||
Cancel = 2 取消 / 强制登出指定座席(NetPacketUtil.cs:47)
|
|
||||||
Error = 3 错误强制登出(GameMain.cs:91)
|
|
||||||
TestIn = 4 测试登入 / 顶号(GameMain.cs:104)
|
|
||||||
Quit = 5 退出时强制登出,可释放残留会话(GameMainObject.cs:200)
|
|
||||||
|
|
||||||
★ 真机会话结束时用的是 type=4(TestIn),见 source/2026-10-05.log:125。
|
|
||||||
|
|
||||||
★ delayLog 结构(source/Assembly-CSharp/Net.VO.Mai2/DelayLog.cs)
|
|
||||||
request 是定长 26 的数组(DelayLog.cs:17 new RequestAPI[26]),
|
|
||||||
每个元素 {count, size, msec, retry}。
|
|
||||||
|
|
||||||
调用示例:
|
|
||||||
from api import UserLogout, LogoutType
|
|
||||||
UserLogout(12345678, token, login.loginDateTime,
|
|
||||||
logout_type=LogoutType.TestIn, region_id=1, place_id=1000)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_MS
|
|
||||||
|
|
||||||
from .log_setup import log_debug, log_error, log_info
|
|
||||||
from .maimai_net import MaimaiNetClient, request_dict
|
|
||||||
|
|
||||||
#: DelayLog.request 的定长(DelayLog.cs:17)
|
|
||||||
DELAY_LOG_SLOTS = 26
|
|
||||||
|
|
||||||
|
|
||||||
class LogoutType:
|
|
||||||
"""登出类型(对应 Net.VO.Mai2.LogoutType)。"""
|
|
||||||
None_ = 0
|
|
||||||
Logout = 1 # 正常登出(带 delayLog)
|
|
||||||
Cancel = 2 # 取消 / 强制登出
|
|
||||||
Error = 3 # 错误强制登出
|
|
||||||
TestIn = 4 # 测试登入 / 顶号(真机会话结束用这个)
|
|
||||||
Quit = 5 # 退出强制登出
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RequestAPI:
|
|
||||||
"""单个接口的统计(对应 Net.VO.Mai2/RequestAPI.cs)。"""
|
|
||||||
count: int = 0
|
|
||||||
size: int = 0
|
|
||||||
msec: int = 0
|
|
||||||
retry: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DelayLog:
|
|
||||||
"""会话网络统计(对应 Net.VO.Mai2/DelayLog.cs)。"""
|
|
||||||
dlRequests: int = 0
|
|
||||||
dlSize: int = 0
|
|
||||||
dlRetry: int = 0
|
|
||||||
loginMsec: int = 0
|
|
||||||
saveMsec: int = 0
|
|
||||||
reductionMusic: int = 0
|
|
||||||
reductionItem: int = 0
|
|
||||||
request: List[RequestAPI] = field(
|
|
||||||
default_factory=lambda: [RequestAPI() for _ in range(DELAY_LOG_SLOTS)]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserLogoutRequestVO:
|
|
||||||
"""简单登出请求 VO(对应 UserLogoutRequestVO)。"""
|
|
||||||
userId: int
|
|
||||||
accessCode: str = ""
|
|
||||||
regionId: int = 0
|
|
||||||
placeId: int = 0
|
|
||||||
clientId: str = ""
|
|
||||||
loginDateTime: int = 0
|
|
||||||
type: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserLogoutRequest2VO:
|
|
||||||
"""完整登出请求 VO(对应 UserLogoutRequest2VO,含 delayLog)。"""
|
|
||||||
userId: int
|
|
||||||
accessCode: str = ""
|
|
||||||
regionId: int = 0
|
|
||||||
placeId: int = 0
|
|
||||||
clientId: str = ""
|
|
||||||
loginDateTime: int = 0
|
|
||||||
type: int = 0
|
|
||||||
delayLog: Optional[DelayLog] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UserLogoutResponseVO:
|
|
||||||
"""响应 VO(对应 UserLogoutResponseVO)。"""
|
|
||||||
returnCode: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
def create_empty_delay_log() -> DelayLog:
|
|
||||||
"""创建全零的 DelayLog(26 个槽位)。"""
|
|
||||||
return DelayLog()
|
|
||||||
|
|
||||||
|
|
||||||
def create_delay_log_from_stats(
|
|
||||||
requests_stats: Optional[List[Dict[str, Any]]] = None,
|
|
||||||
*,
|
|
||||||
dl_requests: int = 0,
|
|
||||||
dl_size: int = 0,
|
|
||||||
dl_retry: int = 0,
|
|
||||||
login_msec: int = 0,
|
|
||||||
save_msec: int = 0,
|
|
||||||
reduction_music: int = 0,
|
|
||||||
reduction_item: int = 0,
|
|
||||||
) -> DelayLog:
|
|
||||||
"""
|
|
||||||
用统计数据构造 DelayLog。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
requests_stats: 每个接口的统计列表,元素形如
|
|
||||||
{"count":1,"size":2090,"msec":73,"retry":0}
|
|
||||||
按 DelayLog.request 的槽位顺序填入(最多 26 个)
|
|
||||||
"""
|
|
||||||
dl = DelayLog(
|
|
||||||
dlRequests=dl_requests, dlSize=dl_size, dlRetry=dl_retry,
|
|
||||||
loginMsec=login_msec, saveMsec=save_msec,
|
|
||||||
reductionMusic=reduction_music, reductionItem=reduction_item,
|
|
||||||
)
|
|
||||||
for i, stat in enumerate(requests_stats or []):
|
|
||||||
if i >= DELAY_LOG_SLOTS:
|
|
||||||
break
|
|
||||||
dl.request[i] = RequestAPI(
|
|
||||||
count=int(stat.get("count", 0)),
|
|
||||||
size=int(stat.get("size", 0)),
|
|
||||||
msec=int(stat.get("msec", 0)),
|
|
||||||
retry=int(stat.get("retry", 0)),
|
|
||||||
)
|
|
||||||
return dl
|
|
||||||
|
|
||||||
|
|
||||||
def UserLogout(
|
|
||||||
user_id: int,
|
|
||||||
token: str,
|
|
||||||
login_date_time: int,
|
|
||||||
*,
|
|
||||||
logout_type: int = LogoutType.TestIn,
|
|
||||||
access_code: str = "",
|
|
||||||
region_id: int = REGION_ID,
|
|
||||||
place_id: int = PLACE_ID,
|
|
||||||
client_id: str = CLIENT_ID,
|
|
||||||
delay_log: Optional[DelayLog] = None,
|
|
||||||
timeout_ms: Optional[int] = None,
|
|
||||||
client: Optional[MaimaiNetClient] = None,
|
|
||||||
) -> UserLogoutResponseVO:
|
|
||||||
"""
|
|
||||||
用户登出。
|
|
||||||
|
|
||||||
参数:
|
|
||||||
user_id: 用户 ID(必填)
|
|
||||||
token: 会话令牌(必填)
|
|
||||||
login_date_time: 登录时刻(必填,取自 UserLogin 的 loginDateTime)
|
|
||||||
logout_type: 登出类型(可选,默认 4=TestIn,与真机一致)
|
|
||||||
access_code: 访问码(可选)
|
|
||||||
region_id: 区域 ID(可选)
|
|
||||||
place_id: 店铺 ID(可选)
|
|
||||||
client_id: 机台短值(可选)
|
|
||||||
delay_log: 会话统计(可选;仅 logout_type=1 时随报文发出)
|
|
||||||
timeout_ms: 超时毫秒(可选)
|
|
||||||
client: 可复用的 MaimaiNetClient(可选)
|
|
||||||
|
|
||||||
返回:
|
|
||||||
UserLogoutResponseVO
|
|
||||||
|
|
||||||
异常:
|
|
||||||
RuntimeError: returnCode != 1
|
|
||||||
"""
|
|
||||||
log_info(
|
|
||||||
"用户登出:user_id=" + str(user_id) + " logout_type=" + str(logout_type)
|
|
||||||
+ " loginDateTime=" + str(login_date_time)
|
|
||||||
)
|
|
||||||
|
|
||||||
if logout_type == LogoutType.Logout:
|
|
||||||
body: Any = UserLogoutRequest2VO(
|
|
||||||
userId=user_id, accessCode=access_code,
|
|
||||||
regionId=region_id, placeId=place_id, clientId=client_id,
|
|
||||||
loginDateTime=login_date_time, type=logout_type,
|
|
||||||
delayLog=delay_log if delay_log is not None else create_empty_delay_log(),
|
|
||||||
)
|
|
||||||
log_debug("使用完整登出报文(含 delayLog)")
|
|
||||||
else:
|
|
||||||
body = UserLogoutRequestVO(
|
|
||||||
userId=user_id, accessCode=access_code,
|
|
||||||
regionId=region_id, placeId=place_id, clientId=client_id,
|
|
||||||
loginDateTime=login_date_time, type=logout_type,
|
|
||||||
)
|
|
||||||
log_debug("使用简单登出报文(不含 delayLog)")
|
|
||||||
|
|
||||||
raw = request_dict(
|
|
||||||
"UserLogoutApi", body,
|
|
||||||
user_id=user_id, client_id=client_id,
|
|
||||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = UserLogoutResponseVO(returnCode=int(raw.get("returnCode", 0) or 0))
|
|
||||||
if resp.returnCode != 1:
|
|
||||||
msg = "登出失败:returnCode=" + str(resp.returnCode)
|
|
||||||
log_error(msg)
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
|
|
||||||
log_info("用户登出成功:returnCode=" + str(resp.returnCode))
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from .log_setup import setup_logging, teardown_logging
|
|
||||||
|
|
||||||
def _main() -> int:
|
|
||||||
setup_logging("userlogout_test")
|
|
||||||
try:
|
|
||||||
if len(sys.argv) < 4:
|
|
||||||
print("用法: python -m api.userlogout <user_id> <token> <loginDateTime> "
|
|
||||||
"[logout_type] [region_id] [place_id]")
|
|
||||||
return 1
|
|
||||||
lt = int(sys.argv[4]) if len(sys.argv) > 4 else LogoutType.TestIn
|
|
||||||
rid = int(sys.argv[5]) if len(sys.argv) > 5 else REGION_ID
|
|
||||||
pid = int(sys.argv[6]) if len(sys.argv) > 6 else PLACE_ID
|
|
||||||
resp = UserLogout(int(sys.argv[1]), sys.argv[2], int(sys.argv[3]),
|
|
||||||
logout_type=lt, region_id=rid, place_id=pid)
|
|
||||||
print("")
|
|
||||||
print("returnCode =", resp.returnCode)
|
|
||||||
return 0
|
|
||||||
except Exception as exc:
|
|
||||||
log_error("用户登出失败:" + str(exc))
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
teardown_logging()
|
|
||||||
|
|
||||||
raise SystemExit(_main())
|
|
||||||
@@ -1,563 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
authv1.py —— ALL.Net(alAbEx v4) 机台认证:Keychip -> clientId / regionId / placeId / placeName / regionName
|
|
||||||
==========================================================================================================
|
|
||||||
|
|
||||||
【解决什么问题】
|
|
||||||
config.py 第二节那六个值
|
|
||||||
KEYCHIP_ID / CLIENT_ID / REGION_ID / PLACE_ID / PLACE_NAME / REGION_NAME
|
|
||||||
全部来自 **AMDaemon 开机时向 ALL.Net 认证服务器要的结果**。
|
|
||||||
游戏服务器(maimai-gm.wahlap.com)**没有任何接口**会返回它们 ——
|
|
||||||
所以本工程此前只能手工从真机报文里抄(见 config.py 里那段吐槽)。
|
|
||||||
|
|
||||||
本脚本把 AMDaemon 的这一步原样重做了一遍。
|
|
||||||
|
|
||||||
【协议出处】—— 全部能在 source/amdaemon.exe 的字符串里对上(数字是文件内偏移)
|
|
||||||
端点 http://at.sys-all.cn/net/initialize @0x788170
|
|
||||||
备用 http://at.sys-allnet.cn/net/initialize 实测同样可用
|
|
||||||
库 alAbEx_lite Ver 4.00.01 (src/lib/alabex_pv4_lite) @0x784a31
|
|
||||||
请求体 "title_id=%s&title_ver=%s&client_id=%s" @0x7882b8
|
|
||||||
请求头 Pragma: DFI @0x7883cc
|
|
||||||
User-Agent: <...>Windows/Lite @0x788508
|
|
||||||
响应字段 result= uri1= uri2= name= nickname= region0=
|
|
||||||
region_name0..3= place_id= setting= country=
|
|
||||||
location_type= client_timezone= res_ver= uri=
|
|
||||||
utc_time= token= @0x784ae8-0x788fd0
|
|
||||||
信封 AES-128-CBC + 16 字节前缀块 + PKCS7
|
|
||||||
(exe @0x63a100 提到 alAbExSetOption(ALE_ABEX_OPTION_AES_KEY))
|
|
||||||
|
|
||||||
【信封细节】实测确认
|
|
||||||
请求 = AES-CBC( 16 字节全 0 前缀 || 明文 ),IV = 16 字节全 0
|
|
||||||
响应 = 16 字节 IV || AES-CBC 密文
|
|
||||||
|
|
||||||
【响应码】
|
|
||||||
result= 1 成功
|
|
||||||
result=-2 机台不存在(client_id 未被收录;小写也会走到这里)
|
|
||||||
result=-3 参数格式错(例如把 15 位完整 Keychip 当 client_id 发出去)
|
|
||||||
|
|
||||||
【用法】
|
|
||||||
python authv1.py A63E01E9580 # 查(打印结果)
|
|
||||||
python authv1.py A63E-01C30471007 # 带连字符的完整 Keychip 也行
|
|
||||||
python authv1.py A63E01E9580 --json # 机器可读
|
|
||||||
python authv1.py A63E01E9580 --write # 强制写回 config.py
|
|
||||||
python authv1.py A63E01E9580 --no-write # 强制不写
|
|
||||||
python authv1.py --batch keychip.csv # 批量查(CSV 第一列是 Keychip)
|
|
||||||
|
|
||||||
★ 是否写回 config.py,默认看 config.py 里的 AUTHV1_WRITE_CONFIG 开关。
|
|
||||||
|
|
||||||
【也当协议库用】
|
|
||||||
from authv1 import allnet_post, initialize, delivery_instruction, parse_keychip
|
|
||||||
netdelivery.py 就是这么用它的。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import ast
|
|
||||||
import csv
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from Crypto.Cipher import AES
|
|
||||||
from Crypto.Util.Padding import pad
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 协议常量
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# ALL.Net 认证服务器(国服 SDGB)。两个域名指向不同机房,实测都能用;
|
|
||||||
# 默认用 sys-allnet.cn(与 maimai-gm.wahlap.com 同机房,链路更短)。
|
|
||||||
ALLNET_HOSTS = (
|
|
||||||
"http://at.sys-allnet.cn",
|
|
||||||
"http://at.sys-all.cn",
|
|
||||||
)
|
|
||||||
DEFAULT_HOST = ALLNET_HOSTS[0]
|
|
||||||
|
|
||||||
PATH_INITIALIZE = "/net/initialize"
|
|
||||||
PATH_DELIVERY_INSTRUCTION = "/net/delivery/instruction"
|
|
||||||
|
|
||||||
TITLE_ID = "SDGB"
|
|
||||||
|
|
||||||
# ALE_ABEX_OPTION_AES_KEY 对应的 16 字节密钥。
|
|
||||||
# 出处:exe @0x63a100 有 alAbExSetOption(ALE_ABEX_OPTION_AES_KEY);
|
|
||||||
# 密钥本体在 exe 里不是明文串,此处取值经真实请求验证有效。
|
|
||||||
AES_KEY = bytes([47, 63, 106, 111, 43, 34, 76, 38, 92, 67, 114, 57, 40, 61, 107, 71])
|
|
||||||
AES_IV = bytes(16)
|
|
||||||
|
|
||||||
# 请求头。依据:exe 里 "Pragma" + "DFI"(@0x7883cc) 与 "Windows/Lite"(@0x788508)。
|
|
||||||
HEADERS = {
|
|
||||||
"User-Agent": "SDGB;Windows/Lite",
|
|
||||||
"Pragma": "DFI",
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_TIMEOUT = 20
|
|
||||||
|
|
||||||
# client_id 恒为 11 位大写十六进制(Keychip 15 位去掉连字符后的前 11 位)
|
|
||||||
CLIENT_ID_LEN = 11
|
|
||||||
CLIENT_ID_RE = re.compile(r"^[0-9A-F]{" + str(CLIENT_ID_LEN) + r"}$")
|
|
||||||
|
|
||||||
# 上报给 ALL.Net 的版本号。真机 1.56 ROM 实测仍报 1.55(与 Mai-Encoding 一致);
|
|
||||||
# 默认取 config.MAI_ENCODING,取不到就退回这个字面量。
|
|
||||||
DEFAULT_TITLE_VER = "1.55"
|
|
||||||
|
|
||||||
RESULT_TEXT = {
|
|
||||||
"1": "成功",
|
|
||||||
"-1": "通用错误",
|
|
||||||
"-2": "机台不存在(client_id 未被 ALL.Net 收录)",
|
|
||||||
"-3": "参数格式错误(client_id 必须是 11 位短值)",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class AllnetError(RuntimeError):
|
|
||||||
"""ALL.Net 交互失败(网络 / 信封 / 业务码非 1)。"""
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 信封:AES-128-CBC
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def encode_request(text: str) -> bytes:
|
|
||||||
"""明文 -> 线上字节流:16 字节全 0 前缀 + PKCS7 + AES-128-CBC(IV=0)。"""
|
|
||||||
body = AES_IV + text.encode("utf-8")
|
|
||||||
return AES.new(AES_KEY, AES.MODE_CBC, AES_IV).encrypt(pad(body, AES.block_size))
|
|
||||||
|
|
||||||
|
|
||||||
def decode_response(raw: bytes) -> str:
|
|
||||||
"""线上字节流 -> 明文:前 16 字节是 IV,其后是 AES-128-CBC 密文。"""
|
|
||||||
if len(raw) < 32 or len(raw) % AES.block_size:
|
|
||||||
raise AllnetError("响应长度异常:%d 字节(应 >=32 且为 16 的倍数)" % len(raw))
|
|
||||||
plain = AES.new(AES_KEY, AES.MODE_CBC, raw[:16]).decrypt(raw[16:])
|
|
||||||
n = plain[-1]
|
|
||||||
if 1 <= n <= AES.block_size:
|
|
||||||
plain = plain[:-n]
|
|
||||||
return plain.decode("utf-8", "replace")
|
|
||||||
|
|
||||||
|
|
||||||
def allnet_post(path, body, *, host=DEFAULT_HOST, timeout=DEFAULT_TIMEOUT, retries=3):
|
|
||||||
"""
|
|
||||||
POST 一条加密请求,返回解密后的明文(形如 "result=1&place_id=...")。
|
|
||||||
|
|
||||||
★ 实测 at.sys-allnet.cn 偶尔会直接掐断连接(RemoteDisconnected),
|
|
||||||
所以这里对**网络层**失败做退避重试;业务码非 1 不重试。
|
|
||||||
"""
|
|
||||||
url = host.rstrip("/") + path
|
|
||||||
payload = encode_request(body)
|
|
||||||
last_error = None
|
|
||||||
for attempt in range(1, retries + 1):
|
|
||||||
try:
|
|
||||||
resp = requests.post(url, data=payload, headers=HEADERS, timeout=timeout)
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
last_error = exc
|
|
||||||
if attempt < retries:
|
|
||||||
time.sleep(1.5 * attempt)
|
|
||||||
continue
|
|
||||||
raise AllnetError("请求 %s 失败(已重试 %d 次):%s" % (url, retries, exc)) from exc
|
|
||||||
|
|
||||||
if resp.status_code != 200:
|
|
||||||
if resp.status_code >= 500 and attempt < retries:
|
|
||||||
last_error = "HTTP %d" % resp.status_code
|
|
||||||
time.sleep(1.5 * attempt)
|
|
||||||
continue
|
|
||||||
raise AllnetError("请求 %s 返回 HTTP %d" % (url, resp.status_code))
|
|
||||||
return decode_response(resp.content)
|
|
||||||
|
|
||||||
raise AllnetError("请求 %s 失败:%s" % (url, last_error))
|
|
||||||
|
|
||||||
|
|
||||||
def parse_kv(text: str) -> dict:
|
|
||||||
"""把 "result=1&place_id=146b&name=%E5%AE%89..." 解析成 dict(值做 URL 解码)。"""
|
|
||||||
out = {}
|
|
||||||
for part in text.strip().split("&"):
|
|
||||||
if not part or "=" not in part:
|
|
||||||
continue
|
|
||||||
key, val = part.split("=", 1)
|
|
||||||
out[key.strip()] = urllib.parse.unquote(val)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Keychip <-> client_id
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def parse_keychip(raw: str) -> str:
|
|
||||||
"""
|
|
||||||
把用户给的 Keychip 归一化成 client_id(11 位大写短值)。
|
|
||||||
|
|
||||||
接受三种写法:
|
|
||||||
完整带连字符 "A63E-01C30471007"
|
|
||||||
完整连写 "A63E01C30471007"
|
|
||||||
短值 "A63E01C3047"
|
|
||||||
|
|
||||||
★ 为什么一定要截前 11 位:实测把完整 15 位当 client_id 发出去,
|
|
||||||
ALL.Net 回 result=-3(格式错)。
|
|
||||||
"""
|
|
||||||
s = re.sub(r"[\s\-]", "", str(raw)).upper()
|
|
||||||
if len(s) < CLIENT_ID_LEN:
|
|
||||||
raise ValueError("Keychip 太短:%r(至少 %d 位)" % (raw, CLIENT_ID_LEN))
|
|
||||||
cid = s[:CLIENT_ID_LEN]
|
|
||||||
if not CLIENT_ID_RE.match(cid):
|
|
||||||
raise ValueError("Keychip 非法:%r(应为十六进制大写)" % raw)
|
|
||||||
return cid
|
|
||||||
|
|
||||||
|
|
||||||
def make_keychip(client_id: str, place_id) -> str:
|
|
||||||
"""
|
|
||||||
按本工程约定拼回完整 Keychip:
|
|
||||||
前 4 位 + "-" + 第 5 位起 + placeId
|
|
||||||
例:A63E01C3047 + 1007 -> A63E-01C30471007
|
|
||||||
"""
|
|
||||||
cid = str(client_id).upper()
|
|
||||||
return "%s-%s%d" % (cid[:4], cid[4:], int(place_id))
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 两个接口
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def initialize(client_id, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
|
|
||||||
timeout=DEFAULT_TIMEOUT):
|
|
||||||
"""POST /net/initialize —— 机台认证,返回原始明文。"""
|
|
||||||
body = "title_id=%s&title_ver=%s&client_id=%s" % (TITLE_ID, title_ver, client_id)
|
|
||||||
return allnet_post(PATH_INITIALIZE, body, host=host, timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def delivery_instruction(client_id, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
|
|
||||||
timeout=DEFAULT_TIMEOUT):
|
|
||||||
"""POST /net/delivery/instruction —— DLI,返回原始明文(含 uri= 配信指示書地址)。"""
|
|
||||||
body = "title_id=%s&title_ver=%s&client_id=%s" % (TITLE_ID, title_ver, client_id)
|
|
||||||
return allnet_post(PATH_DELIVERY_INSTRUCTION, body, host=host, timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def query_keychip(keychip, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
|
|
||||||
timeout=DEFAULT_TIMEOUT, raw_ok=False):
|
|
||||||
"""
|
|
||||||
查一台机台的身份。返回 dict(含 ok / result / 各字段)。
|
|
||||||
|
|
||||||
★ raw_ok=False 时,result != 1 直接抛 AllnetError(CLI 用);
|
|
||||||
raw_ok=True 时照样返回,由调用方自己看 result(批量用)。
|
|
||||||
"""
|
|
||||||
cid = parse_keychip(keychip)
|
|
||||||
text = initialize(cid, title_ver=title_ver, host=host, timeout=timeout)
|
|
||||||
kv = parse_kv(text)
|
|
||||||
|
|
||||||
result = kv.get("result", "")
|
|
||||||
place_hex = kv.get("place_id", "") or ""
|
|
||||||
try:
|
|
||||||
place_id = int(place_hex, 16) if place_hex else None
|
|
||||||
except ValueError:
|
|
||||||
place_id = None
|
|
||||||
|
|
||||||
region_names = [kv.get("region_name%d" % i, "") for i in range(4)]
|
|
||||||
region_names = [n for n in region_names if n]
|
|
||||||
|
|
||||||
try:
|
|
||||||
region_id = int(kv.get("region0", "") or 0)
|
|
||||||
except ValueError:
|
|
||||||
region_id = None
|
|
||||||
|
|
||||||
info = {
|
|
||||||
"ok": result == "1",
|
|
||||||
"result": result,
|
|
||||||
"result_text": RESULT_TEXT.get(result, "未知返回码 %s" % result),
|
|
||||||
"title_id": TITLE_ID,
|
|
||||||
"title_ver": title_ver,
|
|
||||||
"host": host,
|
|
||||||
# --- 六个值里的五个(KEYCHIP_ID 是推导值)---
|
|
||||||
"client_id": cid,
|
|
||||||
"region_id": region_id,
|
|
||||||
"place_id": place_id,
|
|
||||||
"place_name": kv.get("name", ""),
|
|
||||||
"region_name": region_names[0] if region_names else "",
|
|
||||||
# --- 附送 ---
|
|
||||||
"nickname": kv.get("nickname", ""),
|
|
||||||
"region_names": region_names,
|
|
||||||
"country": kv.get("country", ""),
|
|
||||||
"setting": kv.get("setting", ""),
|
|
||||||
"location_type": kv.get("location_type", ""),
|
|
||||||
"game_server_uri": kv.get("uri1", ""),
|
|
||||||
"auth_token": kv.get("uri2", ""),
|
|
||||||
"token": kv.get("token", ""),
|
|
||||||
"utc_time": kv.get("utc_time", ""),
|
|
||||||
"client_timezone": kv.get("client_timezone", ""),
|
|
||||||
"res_ver": kv.get("res_ver", ""),
|
|
||||||
"raw": text.strip(),
|
|
||||||
}
|
|
||||||
if info["place_id"] is not None:
|
|
||||||
info["keychip"] = make_keychip(cid, info["place_id"])
|
|
||||||
else:
|
|
||||||
info["keychip"] = ""
|
|
||||||
|
|
||||||
if not info["ok"] and not raw_ok:
|
|
||||||
raise AllnetError("ALL.Net 返回 result=%s(%s)" % (result, info["result_text"]))
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 写回 config.py
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
CONFIG_FILENAME = "config.py"
|
|
||||||
|
|
||||||
# 写回哪些键(KEYCHIP_ID 是推导值,不写)
|
|
||||||
WRITEBACK_KEYS = ("CLIENT_ID", "REGION_ID", "PLACE_ID", "PLACE_NAME", "REGION_NAME")
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_assignment(text, name, literal):
|
|
||||||
"""把 config.py 里唯一一行 NAME = ... 换成 NAME = literal。"""
|
|
||||||
pattern = re.compile(r"^(%s[ \t]*=[ \t]*).*$" % re.escape(name), re.M)
|
|
||||||
new_text, count = pattern.subn(lambda m: m.group(1) + literal, text, count=1)
|
|
||||||
if count != 1:
|
|
||||||
raise AllnetError("config.py 里找不到唯一的 %s = ... 赋值行(找到 %d 处)" % (name, count))
|
|
||||||
return new_text
|
|
||||||
|
|
||||||
|
|
||||||
def write_back_config(info, *, config_path=None, quiet=False):
|
|
||||||
"""
|
|
||||||
把查询结果写回 config.py。
|
|
||||||
|
|
||||||
安全措施:
|
|
||||||
1. 先 ast.parse 语法自检,写坏了就不落盘
|
|
||||||
2. 落盘前把原文件备份成 config.py.bak
|
|
||||||
3. 保留原文件的换行风格(LF / CRLF)
|
|
||||||
"""
|
|
||||||
path = config_path or os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
||||||
CONFIG_FILENAME)
|
|
||||||
if not os.path.isfile(path):
|
|
||||||
raise AllnetError("找不到 config.py:%s" % path)
|
|
||||||
|
|
||||||
with open(path, "rb") as fp:
|
|
||||||
raw = fp.read()
|
|
||||||
newline = "\r\n" if b"\r\n" in raw else "\n"
|
|
||||||
text = raw.decode("utf-8").replace("\r\n", "\n")
|
|
||||||
|
|
||||||
pairs = [
|
|
||||||
("CLIENT_ID", json.dumps(info["client_id"], ensure_ascii=False)),
|
|
||||||
("REGION_ID", str(info["region_id"])),
|
|
||||||
("PLACE_ID", str(info["place_id"])),
|
|
||||||
("PLACE_NAME", json.dumps(info["place_name"], ensure_ascii=False)),
|
|
||||||
("REGION_NAME", json.dumps(info["region_name"], ensure_ascii=False)),
|
|
||||||
]
|
|
||||||
for name, literal in pairs:
|
|
||||||
text = _replace_assignment(text, name, literal)
|
|
||||||
|
|
||||||
try:
|
|
||||||
ast.parse(text)
|
|
||||||
except SyntaxError as exc:
|
|
||||||
raise AllnetError("改写后 config.py 语法不合法,已放弃写入:%s" % exc) from exc
|
|
||||||
|
|
||||||
shutil.copy2(path, path + ".bak")
|
|
||||||
with open(path, "wb") as fp:
|
|
||||||
fp.write(text.replace("\n", newline).encode("utf-8"))
|
|
||||||
|
|
||||||
if not quiet:
|
|
||||||
print(" [已写回] %s" % path)
|
|
||||||
print(" [已备份] %s.bak" % path)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def config_switch_enabled(config_path=None):
|
|
||||||
"""读 config.py 里的 AUTHV1_WRITE_CONFIG 开关。读不到就当作关。"""
|
|
||||||
path = config_path or os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
||||||
CONFIG_FILENAME)
|
|
||||||
try:
|
|
||||||
with open(path, "rb") as fp:
|
|
||||||
tree = ast.parse(fp.read().decode("utf-8"))
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
for node in tree.body:
|
|
||||||
if isinstance(node, ast.Assign):
|
|
||||||
for target in node.targets:
|
|
||||||
if isinstance(target, ast.Name) and target.id == "AUTHV1_WRITE_CONFIG":
|
|
||||||
try:
|
|
||||||
return bool(ast.literal_eval(node.value))
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 输出
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def format_report(info) -> str:
|
|
||||||
"""人类可读的结果块。"""
|
|
||||||
lines = []
|
|
||||||
lines.append("=== authv1 机台身份查询 ===")
|
|
||||||
lines.append(" Keychip (完整) : %s" % (info["keychip"] or "(查不到)"))
|
|
||||||
lines.append(" clientId (短值) : %s <- 游戏服务器 clientId" % info["client_id"])
|
|
||||||
lines.append(" placeId : %s" % info["place_id"])
|
|
||||||
lines.append(" placeName : %s" % info["place_name"])
|
|
||||||
lines.append(" regionId : %s" % info["region_id"])
|
|
||||||
lines.append(" regionName : %s" % info["region_name"])
|
|
||||||
lines.append(" --- 附送 ---")
|
|
||||||
lines.append(" 游戏服务器 : %s" % info["game_server_uri"])
|
|
||||||
lines.append(" 机台昵称 : %s" % info["nickname"])
|
|
||||||
lines.append(" 国家/地区 : %s" % info["country"])
|
|
||||||
lines.append(" 认证时间(UTC) : %s 时区 %s" % (info["utc_time"], info["client_timezone"]))
|
|
||||||
lines.append(" ALL.Net 返回码 : %s(%s)" % (info["result"], info["result_text"]))
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# CLI
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def build_parser():
|
|
||||||
p = argparse.ArgumentParser(
|
|
||||||
prog="authv1.py",
|
|
||||||
description="ALL.Net(alAbEx v4) 机台认证:Keychip -> clientId/regionId/placeId/placeName/regionName",
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog=(
|
|
||||||
"示例:\n"
|
|
||||||
" python authv1.py A63E01E9580\n"
|
|
||||||
" python authv1.py A63E-01C30471007 --json\n"
|
|
||||||
" python authv1.py A63E01E9580 --write\n"
|
|
||||||
" python authv1.py --batch source/keychip.csv --limit 10\n"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
p.add_argument("keychip", nargs="?", help="Keychip(完整带连字符 / 完整连写 / 11 位短值都行)")
|
|
||||||
p.add_argument("--batch", metavar="CSV",
|
|
||||||
help="批量查询:CSV 文件,默认读第一列(表头含 Keychip 时自动跳过)")
|
|
||||||
p.add_argument("--limit", type=int, default=0, help="批量模式最多查多少条(0 = 不限)")
|
|
||||||
p.add_argument("--ver", default=None, help="上报给 ALL.Net 的版本号(默认取 config.MAI_ENCODING)")
|
|
||||||
p.add_argument("--host", default=DEFAULT_HOST, help="ALL.Net 认证服务器(默认 %s)" % DEFAULT_HOST)
|
|
||||||
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="单次请求超时秒数")
|
|
||||||
p.add_argument("--json", action="store_true", help="输出 JSON")
|
|
||||||
g = p.add_mutually_exclusive_group()
|
|
||||||
g.add_argument("--write", action="store_true", help="强制把结果写回 config.py")
|
|
||||||
g.add_argument("--no-write", action="store_true", help="强制不写回 config.py")
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_title_ver(cli_value):
|
|
||||||
if cli_value:
|
|
||||||
return str(cli_value)
|
|
||||||
try:
|
|
||||||
import config # noqa: F401 同目录
|
|
||||||
return str(getattr(config, "MAI_ENCODING", DEFAULT_TITLE_VER))
|
|
||||||
except Exception:
|
|
||||||
return DEFAULT_TITLE_VER
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
|
||||||
try:
|
|
||||||
sys.stdout.reconfigure(errors="replace")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
args = build_parser().parse_args(argv)
|
|
||||||
title_ver = resolve_title_ver(args.ver)
|
|
||||||
|
|
||||||
if args.batch:
|
|
||||||
return run_batch(args, title_ver)
|
|
||||||
|
|
||||||
if not args.keychip:
|
|
||||||
build_parser().print_help()
|
|
||||||
print("\n错误:请给一个 Keychip,或用 --batch 指定 CSV。")
|
|
||||||
return 2
|
|
||||||
|
|
||||||
try:
|
|
||||||
info = query_keychip(args.keychip, title_ver=title_ver,
|
|
||||||
host=args.host, timeout=args.timeout)
|
|
||||||
except (AllnetError, ValueError) as exc:
|
|
||||||
print("查询失败:%s" % exc)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if args.json:
|
|
||||||
print(json.dumps(info, ensure_ascii=False, indent=1))
|
|
||||||
else:
|
|
||||||
print(format_report(info))
|
|
||||||
|
|
||||||
do_write = decide_write(args)
|
|
||||||
if do_write:
|
|
||||||
try:
|
|
||||||
write_back_config(info)
|
|
||||||
except AllnetError as exc:
|
|
||||||
print("写回失败:%s" % exc)
|
|
||||||
return 1
|
|
||||||
elif not args.json:
|
|
||||||
print(" [未写回] 想让它自动写回:把 config.py 的 AUTHV1_WRITE_CONFIG 改成 True,"
|
|
||||||
"或加 --write")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def decide_write(args):
|
|
||||||
"""--write / --no-write 优先,否则看 config.py 的开关。"""
|
|
||||||
if args.write:
|
|
||||||
return True
|
|
||||||
if args.no_write:
|
|
||||||
return False
|
|
||||||
return config_switch_enabled()
|
|
||||||
|
|
||||||
|
|
||||||
def _disp_width(text):
|
|
||||||
"""按终端显示宽度算长度:中日韩全角字符占 2 列。"""
|
|
||||||
import unicodedata
|
|
||||||
width = 0
|
|
||||||
for ch in str(text):
|
|
||||||
width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
|
||||||
return width
|
|
||||||
|
|
||||||
|
|
||||||
def _pad(text, width):
|
|
||||||
text = str(text)
|
|
||||||
return text + " " * max(0, width - _disp_width(text))
|
|
||||||
|
|
||||||
|
|
||||||
def run_batch(args, title_ver):
|
|
||||||
path = args.batch
|
|
||||||
if not os.path.isfile(path):
|
|
||||||
print("找不到 CSV:%s" % path)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
with open(path, "r", encoding="utf-8-sig", newline="") as fp:
|
|
||||||
reader = csv.reader(fp)
|
|
||||||
for row in reader:
|
|
||||||
if not row or not row[0].strip():
|
|
||||||
continue
|
|
||||||
if row[0].strip().lower() in ("keychip", "keychip id", "keychipid"):
|
|
||||||
continue
|
|
||||||
rows.append(row)
|
|
||||||
if args.limit > 0:
|
|
||||||
rows = rows[: args.limit]
|
|
||||||
|
|
||||||
print("=== authv1 批量查询(%d 台,版本 %s)===" % (len(rows), title_ver))
|
|
||||||
header = " ".join([_pad("clientId", 13), _pad("result", 6), _pad("placeId", 8),
|
|
||||||
_pad("regionId", 8), _pad("区域", 8), "店铺名"])
|
|
||||||
print(header)
|
|
||||||
ok = 0
|
|
||||||
results = []
|
|
||||||
for row in rows:
|
|
||||||
try:
|
|
||||||
info = query_keychip(row[0], title_ver=title_ver, host=args.host,
|
|
||||||
timeout=args.timeout, raw_ok=True)
|
|
||||||
except Exception as exc:
|
|
||||||
print("%s ERROR %s" % (_pad(row[0].strip()[:13], 13), exc))
|
|
||||||
continue
|
|
||||||
results.append(info)
|
|
||||||
if info["ok"]:
|
|
||||||
ok += 1
|
|
||||||
print(" ".join([
|
|
||||||
_pad(info["client_id"], 13),
|
|
||||||
_pad(info["result"], 6),
|
|
||||||
_pad(info["place_id"], 8),
|
|
||||||
_pad(info["region_id"], 8),
|
|
||||||
_pad(info["region_name"], 8),
|
|
||||||
str(info["place_name"]),
|
|
||||||
]))
|
|
||||||
print("--- 成功 %d / %d ---" % (ok, len(rows)))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
-260
@@ -1,260 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
buy_ticket.py —— 主脚本范例:扫码登录 → 购买功能票 → 登出
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
一条完整的「买票」链路:
|
|
||||||
|
|
||||||
ParseQRCode -> GetUserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
|
|
||||||
|
|
||||||
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)得到
|
|
||||||
userChargelog.price(= ticket.creditNum)
|
|
||||||
5. 闸门 → 发包
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
用法
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
python buy_ticket.py <二维码> # 预检:只看报文,不发包
|
|
||||||
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
|
|
||||||
python buy_ticket.py <二维码> --charge-id 2 --send --skip-entry # 跳过入场数据下载
|
|
||||||
|
|
||||||
付费票(api.PAID_TICKET_IDS):
|
|
||||||
2 = 2倍チケット creditNum 1
|
|
||||||
3 = 3倍チケット creditNum 2
|
|
||||||
4 = 4倍チケット creditNum 3
|
|
||||||
5 = 5倍チケット creditNum 4
|
|
||||||
6 = 6倍チケット creditNum 5
|
|
||||||
|
|
||||||
★ 真实购买会写入一条购买记录(不可撤销)。不传 --send 时只构造报文。
|
|
||||||
|
|
||||||
★ 二维码有**时效性**(**有效期 10 分钟**),过期后 aimedb 返回 errorID=2
|
|
||||||
—— **不是按次数计量的**,重新生成即可。
|
|
||||||
|
|
||||||
★ 购买前会先查一次功能票;**账号已持有功能票时直接拒绝购买**(重复购买有封号风险)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
from api import (
|
|
||||||
setup_logging,
|
|
||||||
teardown_logging,
|
|
||||||
log_error,
|
|
||||||
log_info,
|
|
||||||
ParseQRCode,
|
|
||||||
GetUserPreview,
|
|
||||||
UserLogin,
|
|
||||||
UserLogout,
|
|
||||||
LogoutType,
|
|
||||||
UpsertUserChargelog,
|
|
||||||
ChargelogConfirmError,
|
|
||||||
ChargelogGuardError,
|
|
||||||
GetUserCharge,
|
|
||||||
GetGameCharge,
|
|
||||||
ticket_name,
|
|
||||||
GetNeedUserData,
|
|
||||||
summarize_snapshot,
|
|
||||||
Ping,
|
|
||||||
)
|
|
||||||
from config import PLACE_ID, REGION_ID
|
|
||||||
|
|
||||||
|
|
||||||
def show_charges(user_id, label):
|
|
||||||
"""
|
|
||||||
★ 调 GetUserChargeApi 打印当前票券状态,并返回 stock > 0 的那些。
|
|
||||||
|
|
||||||
★★ 为什么跑前跑后都要查:
|
|
||||||
真机只在「本局要用这张票」时才购买,买到手会立刻被用掉(stock--),
|
|
||||||
所以正常状态 stock 恒为 0。
|
|
||||||
**已经持票还去买,服务端看到的是重复购买,有封号风险。**
|
|
||||||
所以买之前必须确认没有持票;买之后要确认状态符合预期。
|
|
||||||
"""
|
|
||||||
raw = GetUserCharge(user_id)
|
|
||||||
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
|
|
||||||
log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:")
|
|
||||||
if not lst:
|
|
||||||
log_info(" (空)")
|
|
||||||
for c in lst:
|
|
||||||
cid = c.get("chargeId")
|
|
||||||
stock = int(c.get("stock", 0) or 0)
|
|
||||||
log_info(" chargeId=" + str(cid) + " " + ticket_name(cid)
|
|
||||||
+ " stock=" + str(stock)
|
|
||||||
+ " purchaseDate=" + str(c.get("purchaseDate"))
|
|
||||||
+ " validDate=" + str(c.get("validDate"))
|
|
||||||
+ (" ← ★ 持有中" if stock > 0 else ""))
|
|
||||||
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
|
|
||||||
log_info("[" + label + "] 持有中的功能票:" + str(len(held)) + " 张")
|
|
||||||
return held
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv):
|
|
||||||
opts = {"qr": "", "charge_id": 5, "send": False, "list": False,
|
|
||||||
"debug": False, "skip_entry": False, "wait_before": 30.0}
|
|
||||||
i = 0
|
|
||||||
while i < len(argv):
|
|
||||||
a = argv[i]
|
|
||||||
if a == "--send":
|
|
||||||
opts["send"] = True
|
|
||||||
elif a == "--list":
|
|
||||||
opts["list"] = True
|
|
||||||
elif a == "--debug":
|
|
||||||
opts["debug"] = True
|
|
||||||
elif a == "--skip-entry":
|
|
||||||
opts["skip_entry"] = True
|
|
||||||
elif a == "--wait-before" and i + 1 < len(argv):
|
|
||||||
opts["wait_before"] = float(argv[i + 1])
|
|
||||||
i += 1
|
|
||||||
elif a == "--charge-id" and i + 1 < len(argv):
|
|
||||||
opts["charge_id"] = int(argv[i + 1])
|
|
||||||
i += 1
|
|
||||||
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"]:
|
|
||||||
print(__doc__)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if opts["debug"]:
|
|
||||||
import config as _cfg
|
|
||||||
_cfg.LOG_LEVEL = "debug"
|
|
||||||
|
|
||||||
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)。
|
|
||||||
Ping()
|
|
||||||
|
|
||||||
user_id, token = ParseQRCode(opts["qr"])
|
|
||||||
session.update(user_id=user_id, token=token)
|
|
||||||
|
|
||||||
preview = GetUserPreview(user_id, token)
|
|
||||||
if preview.isLogin:
|
|
||||||
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
|
|
||||||
return 2
|
|
||||||
|
|
||||||
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
|
|
||||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
|
||||||
|
|
||||||
# ---- ★ 复刻真机的「入场」序列:登录后先把用户数据全量拉一遍 ----
|
|
||||||
# 真机 13:19:45 登录 -> 13:19:46~13:19:51 拉 47 个节点
|
|
||||||
# -> 13:20:10 才发 UpsertUserChargelogApi
|
|
||||||
# 本脚本原来 login 后直接买,跳过了这一段。
|
|
||||||
if not opts["skip_entry"]:
|
|
||||||
snap = GetNeedUserData(user_id, token=token,
|
|
||||||
place_id=PLACE_ID, region_id=REGION_ID)
|
|
||||||
_sum = summarize_snapshot(snap)
|
|
||||||
log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 "
|
|
||||||
+ str(len(snap.get("_failed") or [])) + " 个")
|
|
||||||
|
|
||||||
# ---- ★★ 跑前:必须确认账号当前**没有**持有功能票 ----
|
|
||||||
held_before = show_charges(user_id, "购买前")
|
|
||||||
|
|
||||||
aborted = False
|
|
||||||
if held_before:
|
|
||||||
log_error("账号当前已持有功能票,**拒绝购买**(重复购买有封号风险):")
|
|
||||||
for c in held_before:
|
|
||||||
log_error(" chargeId=" + str(c.get("chargeId"))
|
|
||||||
+ " " + ticket_name(c.get("chargeId"))
|
|
||||||
+ " stock=" + str(c.get("stock"))
|
|
||||||
+ " validDate=" + str(c.get("validDate")))
|
|
||||||
log_error("请先在机台把票用掉,或等它过期后再来。")
|
|
||||||
aborted = True
|
|
||||||
else:
|
|
||||||
# ---- ★ 买票前等待(贴近真机节奏:真机 login 13:19:45 -> 买票 13:20:10,间隔 25s)----
|
|
||||||
if opts["wait_before"] > 0:
|
|
||||||
log_info("买票前等待 " + str(opts["wait_before"]) + " 秒 ...")
|
|
||||||
time.sleep(opts["wait_before"])
|
|
||||||
log_info("等待结束,开始购买")
|
|
||||||
|
|
||||||
# ---- 购买 ----
|
|
||||||
# ★ 一个函数搞定:取当前票券/档案 -> 安全检查 -> 算 validDate/price -> 闸门 -> 发包
|
|
||||||
UpsertUserChargelog(
|
|
||||||
user_id, opts["charge_id"], login.loginDateTime,
|
|
||||||
dry_run=not opts["send"],
|
|
||||||
confirm=opts["send"],
|
|
||||||
guard_no_stock=True,
|
|
||||||
place_id=PLACE_ID, region_id=REGION_ID,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---- ★★ 跑后:无论有没有真发包,都再查一次 ----
|
|
||||||
# (中止时也查,确认状态确实没被动过)
|
|
||||||
held_after = show_charges(user_id, "购买后")
|
|
||||||
|
|
||||||
if aborted:
|
|
||||||
log_info("已按安全检查中止,**未发包**;上面是中止后的复查结果")
|
|
||||||
if len(held_after) == len(held_before):
|
|
||||||
log_info("复查确认:票券状态未发生变化 ✅")
|
|
||||||
else:
|
|
||||||
log_warning("复查发现票券数量变了 —— 请人工确认")
|
|
||||||
return 3
|
|
||||||
|
|
||||||
if not opts["send"]:
|
|
||||||
log_info("[dry-run] 未发包。想真实购买请加 --send(★ 会写入购买记录)")
|
|
||||||
return 0
|
|
||||||
if held_after:
|
|
||||||
log_info("购买后持有 " + str(len(held_after)) + " 张票(购买成功)")
|
|
||||||
else:
|
|
||||||
log_warning("购买后没有查到持有中的票 —— 服务端可能没落库,"
|
|
||||||
"或票被立刻消耗了")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
except ChargelogGuardError:
|
|
||||||
return 3
|
|
||||||
except SystemExit:
|
|
||||||
raise
|
|
||||||
except ChargelogConfirmError:
|
|
||||||
return 5
|
|
||||||
except Exception as exc:
|
|
||||||
log_error(type(exc).__name__ + ": " + str(exc))
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
if session["logged_in"]:
|
|
||||||
try:
|
|
||||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
|
||||||
logout_type=LogoutType.TestIn,
|
|
||||||
region_id=REGION_ID, place_id=PLACE_ID)
|
|
||||||
except Exception as exc:
|
|
||||||
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
|
|
||||||
teardown_logging()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
config.py —— 舞萌DX 纯网络脚本工程 · 运行时可配置项
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
★★ 这是**模板文件**。第一次使用请先复制一份:
|
|
||||||
|
|
||||||
cp config.py.example config.py # Linux / macOS
|
|
||||||
copy config.py.example config.py # Windows
|
|
||||||
|
|
||||||
然后**必须**填好第二节「机台与店铺标识」的 6 个值,否则脚本发出去的请求
|
|
||||||
带的是别人的机台身份,服务端会拒绝。
|
|
||||||
|
|
||||||
★ config.py 已在 .gitignore 里 —— 它属于你的私有配置,不要提交。
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
本文件集中存放**用户可能需要修改**的内容,其余协议常量写死在各自脚本内。
|
|
||||||
|
|
||||||
分六块:
|
|
||||||
1. 网络与加密常量(AES 密钥、协议版本、服务器地址)
|
|
||||||
2. 机台与店铺标识(Keychip、区域、店铺)
|
|
||||||
3. 扫码登录常量(aimedb)
|
|
||||||
4. ★ UpsertUserAll 即将上传的歌曲数据(默认取 source/2026-10-05.log 真机样本)
|
|
||||||
5. UpsertUserAll 安全闸门
|
|
||||||
6. 日志级别
|
|
||||||
|
|
||||||
权威来源全部标注在注释里,改之前请先看依据。
|
|
||||||
"""
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 一、网络与加密常量
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# ★ BASE_URL / AES_KEY / AES_IV / OBFUSCATE_PARAM 已下沉到 api/maimai_net.py
|
|
||||||
# --- 报文版本标识 ---
|
|
||||||
# 依据:source/Assembly-CSharp/Net/NetHttpClient.cs:69 DefaultHttpHeaderEncryption = "1.55"
|
|
||||||
# (请求头 Mai-Encoding 的值;1.56 ROM 实测仍发 1.55)
|
|
||||||
MAI_ENCODING = "1.55"
|
|
||||||
|
|
||||||
# --- 超时(毫秒)---
|
|
||||||
# 依据:source/Assembly-CSharp/Net/NetConfig.cs:7-11
|
|
||||||
TIMEOUT_MS = 10000 # 普通接口
|
|
||||||
TIMEOUT_LONG_MS = 60000 # 登录 / 结算上传
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 二、机台与店铺标识
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# 客户端 ID(Keychip 短值)
|
|
||||||
# 依据:source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
|
|
||||||
# clientId = AMDaemon.System.KeychipId.ShortValue
|
|
||||||
# 实测:真机日志里 clientId 恒等于 Keychip 短值
|
|
||||||
# ★ 形态 = 15 位 Keychip 去掉连字符后的**前 11 位**(大写十六进制)。
|
|
||||||
CLIENT_ID = "" # ★★ 必填:形如 "A63E01C3047"
|
|
||||||
|
|
||||||
# 区域 ID / 店铺 ID
|
|
||||||
# 依据:source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogin.cs:24-25
|
|
||||||
# regionId = Auth.RegionCode ; placeId = Auth.LocationId
|
|
||||||
# 实测:真机日志里的 regionId / placeId 与机台所在地一致
|
|
||||||
REGION_ID = 0 # ★★ 必填:你所在区域 ID
|
|
||||||
PLACE_ID = 0 # ★★ 必填:你所在店铺 ID
|
|
||||||
|
|
||||||
# Keychip ID(机台唯一标识,完整格式带连字符)
|
|
||||||
# ★★ 这是你自己的机台身份,不要外传;填错服务端会拒。
|
|
||||||
# 依据:source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
|
|
||||||
# KeychipID = AMDaemon.System.KeychipId.Value
|
|
||||||
# ★ 组成 = CLIENT_ID 的前 4 位 + "-" + CLIENT_ID 第 5 位起 + PLACE_ID
|
|
||||||
# 例:CLIENT_ID "A63E01C3047" + PLACE_ID 1007 -> "A63E-01C30471007"
|
|
||||||
# 所以它由上面两个值**推导**,不要手改。
|
|
||||||
KEYCHIP_ID = (CLIENT_ID[:4] + "-" + CLIENT_ID[4:] + str(PLACE_ID)) if CLIENT_ID else ""
|
|
||||||
|
|
||||||
# 店铺名 / 区域名
|
|
||||||
#
|
|
||||||
# ★ 为什么以前必须手工填:**游戏服务器没有任何接口返回店铺名**。
|
|
||||||
# 逐行扫过 source/2026-10-05.log 全部响应:
|
|
||||||
# - GetGameSettingApi 只回 gameSetting,无店名
|
|
||||||
# - GetUserDataApi 的 lastPlaceName 回的是 null
|
|
||||||
# - GetUserRegionApi 只回 regionId/playCount/created
|
|
||||||
# 店铺名来自机台本地 AMDaemon:
|
|
||||||
# source/AMDaemon.NET/AMDaemon/Allnet/Auth.cs:33 LocationName <- allnet_Auth_getLocationName()
|
|
||||||
# 是开机时 ALL.Net 认证下发的,客户端本地就有,不会向游戏服务器索取。
|
|
||||||
#
|
|
||||||
# ★★ 现在不用手工抄了:python authv1.py <你的Keychip> 会直接问 ALL.Net
|
|
||||||
# 认证服务器要回 name / region_name0。把下面的 AUTHV1_WRITE_CONFIG 打开,
|
|
||||||
# 它还能把结果自动写回 config.py。
|
|
||||||
PLACE_NAME = "" # ★★ 必填:店铺名
|
|
||||||
REGION_NAME = "" # ★★ 必填:区域名
|
|
||||||
|
|
||||||
|
|
||||||
# --- authv1 回写开关 ---
|
|
||||||
#
|
|
||||||
# authv1.py(Keychip -> 机台身份)查到结果后,是否**自动写回 config.py**。
|
|
||||||
#
|
|
||||||
# True : 每次查询成功后,把 CLIENT_ID / REGION_ID / PLACE_ID /
|
|
||||||
# PLACE_NAME / REGION_NAME 五个值改写进 config.py。
|
|
||||||
# (KEYCHIP_ID 是推导值,会自动跟着变,不用写。)
|
|
||||||
# 改写前会把原文件备份成 config.py.bak,写坏不落盘。
|
|
||||||
# False : 只打印结果,不动 config.py。(默认,安全)
|
|
||||||
#
|
|
||||||
# 命令行可临时覆盖:--write 强制写 / --no-write 强制不写。
|
|
||||||
AUTHV1_WRITE_CONFIG = False
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 三、扫码登录常量(aimedb)
|
|
||||||
# =============================================================================
|
|
||||||
#
|
|
||||||
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
|
|
||||||
# + docs/舞萌DX_扫码登录协议技术规范.md
|
|
||||||
|
|
||||||
# ★ aimedb / QR 常量已下沉到 api/chime.py
|
|
||||||
# =============================================================================
|
|
||||||
# 三之二、★ 功能票(Ticket)主数据
|
|
||||||
# =============================================================================
|
|
||||||
#
|
|
||||||
# 依据:游戏本地主数据
|
|
||||||
# D:\SDGB2026\Package\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml
|
|
||||||
# 源码侧 Manager.MaiStudio.TicketData.creditNum
|
|
||||||
#
|
|
||||||
# ★ 为什么脚本需要它:
|
|
||||||
# UpsertUserChargelogApi(购买功能票)的 userChargelog.price 取的是
|
|
||||||
# **ticket.creditNum(票的信用点倍数)**,不是 GetGameChargeApi 里的 money price:
|
|
||||||
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:410
|
|
||||||
# result.price = ticket.creditNum;
|
|
||||||
#
|
|
||||||
# ticketKind 含义(Ticket.xml 里的枚举):
|
|
||||||
# 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
|
|
||||||
|
|
||||||
#: 买票闸门(和 UpsertUserAll 一样,默认不放行)
|
|
||||||
UPSERT_CHARGELOG_CONFIRM = False
|
|
||||||
# =============================================================================
|
|
||||||
# 四、★ UpsertUserAll 即将上传的歌曲数据
|
|
||||||
# =============================================================================
|
|
||||||
#
|
|
||||||
# ★ 为什么放在 config:
|
|
||||||
# 一局游玩的成绩是**唯一无法由脚本自行产生**的输入 ——
|
|
||||||
# 纯网络脚本复刻不出游玩过程,服务端要的就是这份原始成绩。
|
|
||||||
# 换歌 / 换成绩只改这里,不用动任何 api 脚本。
|
|
||||||
#
|
|
||||||
# ★ 默认值 = source/2026-10-05.log:123 的**真机跑通报文**(UpsertUserAllApi 请求)
|
|
||||||
# 即 userPlaylogList[0] 逐字段照抄。
|
|
||||||
#
|
|
||||||
# ★ 两个"歌曲数据"的区别(别混):
|
|
||||||
# userPlaylogList 本局**原始判定输入** —— 服务端据此算分、写战绩表
|
|
||||||
# userMusicDetailList 按 (musicId, level) 的**历史归档** —— 本局那条 playCount +1
|
|
||||||
# 下面 PLAY_* 同时喂给这两处,保证一致。
|
|
||||||
|
|
||||||
# --- 曲目与难度 ---
|
|
||||||
PLAY_MUSIC_ID = 143 # 曲目 ID
|
|
||||||
PLAY_MUSIC_LEVEL = 4 # 0=Basic 1=Advanced 2=Expert 3=Master 4=Re:Master
|
|
||||||
PLAY_TRACK_NO = 1 # 本局第几首
|
|
||||||
|
|
||||||
# --- 版本号(同一版本两种写法,两处都要)---
|
|
||||||
PLAY_VERSION_INT = 1056000 # 整数形式,用于 userPlaylogList[].version(1.56.00)
|
|
||||||
PLAY_VERSION_STR = "1.56.00" # 字符串形式,用于 userGamePlaylogList[].version
|
|
||||||
|
|
||||||
# --- 成绩 ---
|
|
||||||
PLAY_ACHIEVEMENT = 0 # 达成率 ×10000(0 = 本局失败)
|
|
||||||
PLAY_DELUXSCORE = 0 # DX 分数
|
|
||||||
PLAY_SCORE_RANK = 0 # 评级档(0 = 无)
|
|
||||||
PLAY_IS_CLEAR = False # 是否 Clear
|
|
||||||
|
|
||||||
PLAY_MAX_COMBO = 0
|
|
||||||
PLAY_TOTAL_COMBO = 655 # 该谱面 note 总数(= 各类型判定数之和)
|
|
||||||
PLAY_MAX_SYNC = 0
|
|
||||||
PLAY_TOTAL_SYNC = 0
|
|
||||||
PLAY_COMBO_STATUS = 0 # 0=无 1=全连
|
|
||||||
PLAY_SYNC_STATUS = 0
|
|
||||||
|
|
||||||
PLAY_FAST_COUNT = 0
|
|
||||||
PLAY_LATE_COUNT = 0
|
|
||||||
|
|
||||||
# --- 判定明细 {类型: {判定位: 次数}} ---
|
|
||||||
# 真机样本是一局**失败**的成绩(全 Miss)
|
|
||||||
PLAY_JUDGE_COUNTS = {
|
|
||||||
"tap": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 441},
|
|
||||||
"hold": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 19},
|
|
||||||
"slide": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 192},
|
|
||||||
"touch": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 0},
|
|
||||||
"break": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 3},
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 评级(本局前后)---
|
|
||||||
# ★ PLAY_BEFORE/AFTER_RATING 等已改为运行时从服务器快照取(derive_play_rating)
|
|
||||||
# --- 曲种索引 ---
|
|
||||||
# ★ 必须与 userExtend.categoryIndex 一致(真机两处都是 2022)
|
|
||||||
PLAY_CATEGORY_INDEX = 2022
|
|
||||||
PLAY_MUSIC_INDEX = 10 # userExtend.musicIndex
|
|
||||||
PLAY_EXTRA_FLAG = 1721 # userExtend.extraFlag
|
|
||||||
|
|
||||||
# --- 本局使用的角色快照(characterId1..5 / Level / Awakening)---
|
|
||||||
#
|
|
||||||
# ★ 语义:**进入游玩时**的快照值,不要加经验 —— 这一点极易搞错。
|
|
||||||
# (结算后的等级在 upsertUserAll.userCharacterList 里,那处才 +经验)
|
|
||||||
# 真机实证(source/2026-10-05.log:123 同一份报文):
|
|
||||||
# userPlaylogList[0].characterLevel1..5 = 3800/4455/134/134/134 ← 快照(游玩前)
|
|
||||||
# upsertUserAll.userCharacterList = 3801/4456/135/135/135 ← 结算后
|
|
||||||
#
|
|
||||||
# ★★ 通常**不用改这里**:main.py 会调用 derive_play_characters(snapshot),
|
|
||||||
# 直接从服务器刚下发的 userData.charaSlot + userCharacterList 推出游玩前等级,
|
|
||||||
# 并覆盖本配置。只有拿不到快照(离线组包)时才回退到这里。
|
|
||||||
#
|
|
||||||
# 默认值 = 真机 charaSlot [395, 101, 400402, 400401, 200401] 的游玩前等级
|
|
||||||
# --- 本局消耗 ---
|
|
||||||
# ★ playCredit 语义(VOExtensions.cs:838-845):
|
|
||||||
# playCredit = Credit.GameCostEnoughPlay()(投币成本) + TicketManager.GetTicketCredit(useTicketId)
|
|
||||||
# 即"这一局总共折合多少 credit",不是余额、不是曲目数。真机样本 = 1。
|
|
||||||
PLAY_CREDIT = 1
|
|
||||||
# ★ -1 = 不使用票。依据 TicketManager.cs:47 初始化 _useTicketId[i] = -1;
|
|
||||||
# GetTicketCredit(-1) 里 GetTicket(-1) 返回 null -> 返回 0,即不用票不折算 credit。
|
|
||||||
PLAY_USE_TICKET_ID = -1
|
|
||||||
PLAY_MODE = 0 # 0=通常
|
|
||||||
PLAY_IS_TUTORIAL = False
|
|
||||||
PLAY_IS_EVENT_MODE = False
|
|
||||||
PLAY_IS_NEW_FREE = False
|
|
||||||
PLAY_IS_FREEDOM_MODE = False
|
|
||||||
|
|
||||||
# --- 机台本地计数器(★ 无法从服务器推导,只能手填)---
|
|
||||||
#
|
|
||||||
# 依据:source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:881
|
|
||||||
# result.playCount = Backup.localParameter.playCount
|
|
||||||
# 这是机台**本地**的游玩计数器,任何 Get 接口都不会返回它,
|
|
||||||
# 所以既推不出来也不会被服务端纠正 —— 填错只是个统计数字不对。
|
|
||||||
# 取值 = 该机台当天/本轮的累计游玩局数(每打一局 +1)。
|
|
||||||
# 真机实测:19:21 那一局 = 9,22:31 那一局 = 10。
|
|
||||||
# ★ PLAY_LOCAL_PLAY_COUNT 恒为 2(已下沉到 api/upsert_user_all.py)
|
|
||||||
# --- ★ playSpecial 里的 "CalcSpecialNum"(机台硬件标志位)---
|
|
||||||
#
|
|
||||||
# 依据:source/Assembly-CSharp/Manager/GameManager.cs:332-378
|
|
||||||
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:846-855
|
|
||||||
#
|
|
||||||
# playSpecial = 位反转32( Random.Range(1,1037933) * 2069 + CalcSpecialNum() )
|
|
||||||
#
|
|
||||||
# CalcSpecialNum() 是**按位或**出来的机台特征掩码,逐位含义(源码原文):
|
|
||||||
# 0x001 SpecialAP (测试/特殊 AP)
|
|
||||||
# 0x002 IsAutoPlay()
|
|
||||||
# 0x004 Win32.getRawInputDeviceCount 的 keyboard > 0
|
|
||||||
# 0x008 同上 mouse > 0
|
|
||||||
# 0x010 config.IsAutoPlay / IsDebugDisp / IsAllOpen
|
|
||||||
# 0x020 AMDaemon.System.IsDevelop
|
|
||||||
# 0x040 !SystemInfo.deviceName.Contains("ACA") ← 真机是 ACA,故该位为 0
|
|
||||||
# 0x080 !graphicsDeviceName.Contains("GeForce GTX") ← 真机是 GTX,故该位为 0
|
|
||||||
# 0x100 !processorType.Contains("Core(TM) i3-8100") ← 真机是 i3-8100,故该位为 0
|
|
||||||
# 0x400 无条件置位(源码 GameManager.cs:375) ← ★ 恒为 1
|
|
||||||
#
|
|
||||||
# ★ 真机实测(gamehook 抓到 CalcSpecialNum 调用):
|
|
||||||
# [CalcSpecialNum] = 1024 (SpecialAP=False IsFreedomMode=False)
|
|
||||||
# 1024 = 0x400,即除"无条件置位"那一位外全部为 0 —— 正好是一台标准 ACA 机台。
|
|
||||||
#
|
|
||||||
# ★ 反推验证:真机 playSpecial = 892014272
|
|
||||||
# 位反转32(892014272) = 57726124 = 27900 * 2069 + 1024
|
|
||||||
# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。
|
|
||||||
#
|
|
||||||
# 改成 0 不会报错,但 playSpecial 的位模式就不是真机那种了。
|
|
||||||
# ★ PLAY_SPECIAL_NUM = 1024 已下沉到 api/upsert_user_all.py
|
|
||||||
# --- 用户总游玩次数增量 ---
|
|
||||||
#
|
|
||||||
# ★ 默认 0 = **userData.playCount / currentPlayCount 都不变**(本局不计入总次数)。
|
|
||||||
# 想复刻真机的「+1」就改成 1。
|
|
||||||
#
|
|
||||||
# 为什么默认改成 0:
|
|
||||||
# playCount 是账号的**总游玩次数**,是玩家可见的统计值;
|
|
||||||
# 本工程做「成绩上传」时不需要改动它,保持与上传前一致更安全。
|
|
||||||
# ★ 改成 0 之后的副作用:「落库回查」不能再靠 playCount 是否 +1 判断,
|
|
||||||
# 会自动改用 lastPlayDate 是否更新来判断(见 verify_upsert_landing)。
|
|
||||||
PLAY_USER_PLAY_COUNT_GAIN = 0
|
|
||||||
|
|
||||||
# --- 曲目历史游玩次数增量 ---
|
|
||||||
#
|
|
||||||
# ★ 默认 0 = 本局**不改动**该曲目的历史游玩次数。
|
|
||||||
# ★★ 注意语义:是「条目照传、次数不变」,**不是**「不回传这个节点」。
|
|
||||||
# 实测(同一份代码、同一账号,唯一差别就是这个节点):
|
|
||||||
# userMusicDetailList 回传 1 条 -> returnCode=1 且 playCount 真的 +1(落库)
|
|
||||||
# userMusicDetailList 留空 -> returnCode=1 但 playCount 纹丝不动(**没落库**)
|
|
||||||
# 所以「不想让乐曲 playCount 增加」必须靠把**增量**设成 0,
|
|
||||||
# 而不是把整个节点清空 —— 后者会让整包被服务端静默丢弃。
|
|
||||||
#
|
|
||||||
# 想复刻真机的「该谱面 playCount +1」就改成 1。
|
|
||||||
PLAY_MUSIC_PLAY_COUNT_GAIN = 0
|
|
||||||
|
|
||||||
# --- 地图进度增量 ---
|
|
||||||
#
|
|
||||||
# ★ 默认 0 = **本局不推进地图**,userMapList 回传空数组。
|
|
||||||
# 想复刻真机的"地图 +11000"就改成 11000(真机样本:mapId=550003 的
|
|
||||||
# distance 1424000 -> 1435000)。
|
|
||||||
#
|
|
||||||
# 依据:source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs 的地图导出逻辑
|
|
||||||
# —— 没变化的条目不会进差分结果。
|
|
||||||
PLAY_MAP_DISTANCE_GAIN = 0
|
|
||||||
|
|
||||||
# --- 组队角色等级增量 ---
|
|
||||||
#
|
|
||||||
# ★ 默认 0 = **本局不给角色加经验**,userCharacterList 回传空数组。
|
|
||||||
# 想复刻真机的"组队 5 只各 +1 级"就改成 1(真机样本 4455->4456 等)。
|
|
||||||
#
|
|
||||||
# ★ 注意:设成 0 时 userCharacterList 差集为空 -> 该节点不回传,
|
|
||||||
# 这是**预期的**,不是错误;validate_before_send 也只在你把增量设成非 0
|
|
||||||
# 却又没产出条目时才告警。
|
|
||||||
PLAY_CHARACTER_EXP_GAIN = 0
|
|
||||||
|
|
||||||
# --- ★ 关于 userGetPointList / userLoginBonusList(留空即可)---
|
|
||||||
#
|
|
||||||
# 这两个节点是**登录奖励发放**才产生的,普通游玩结算不需要回传:
|
|
||||||
#
|
|
||||||
# userGetPointList(里程/点数明细)
|
|
||||||
# 真机 39 次 UpsertUserAll 样本里,**25 次为空(64%)**;
|
|
||||||
# 只有发生登录奖励/任务奖励时才非空(如 getKind=1 point=200)。
|
|
||||||
# 留空 => 服务端不累加 point,账户点数保持不变。
|
|
||||||
#
|
|
||||||
# userLoginBonusList(签到奖励)
|
|
||||||
# 同理,只有本次登录触发新签到才回传那一条(isCurrent=true)。
|
|
||||||
# 留空 => isNewLoginBonusList 也是空串。
|
|
||||||
#
|
|
||||||
# ★ 连带影响(正常现象):留空后 userData.point 保持基线值不变。
|
|
||||||
#
|
|
||||||
# 源码参考(如需还原发放行为):
|
|
||||||
# Manager/UserData.cs:57-62 enum MaiMileGetKind { Mission=1, FriendBonus=2, Present=3 }
|
|
||||||
# Manager/UserData.cs:352-367 AddMissionPointLog / AddFriendBonusPointLog
|
|
||||||
|
|
||||||
# --- 店铺库存查询清单(不填则跳过该节点)---
|
|
||||||
PLAY_SHOP_ITEM_IDS = []
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 五、UpsertUserAll 安全闸门
|
|
||||||
# =============================================================================
|
|
||||||
#
|
|
||||||
# ★ 这是**唯一有副作用**的接口:会把本局成绩真实写进服务器,不可撤销。
|
|
||||||
# 保持 False(默认),调用时必须显式传 confirm=True 才真正发包。
|
|
||||||
# 改成 True 会永久放行(不建议)。
|
|
||||||
UPSERT_CONFIRM = False
|
|
||||||
|
|
||||||
# 真实发包前的冷却等待(秒)
|
|
||||||
#
|
|
||||||
# ★ 语义:**每次**真实发送 UpsertUserAll 之前,都固定等待这么多秒(含首次)。
|
|
||||||
# 目的:让上报节奏贴近真机 —— 真机从登录到结算上报约 80 秒,
|
|
||||||
# 不会登录后立刻上报(source/2026-10-05.log:25 登录 → :123 上报,间隔 81 秒)。
|
|
||||||
#
|
|
||||||
# ★ 只作用于**真实发送**(confirm=True);dry_run 不等待。
|
|
||||||
# ★ 冷却发生在「发包前自检」之后,所以自检有问题时会先看到告警,不必白等。
|
|
||||||
# ★ 调试时想跳过等待:把这里改成 0,或调用时传 cooldown_sec=0。
|
|
||||||
UPSERT_SEND_COOLDOWN_SEC = 60
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 六、日志级别
|
|
||||||
# =============================================================================
|
|
||||||
#
|
|
||||||
# "info" 只输出 api 脚本的运行情况与结果:
|
|
||||||
# === XxxApi 开始运行 ===
|
|
||||||
# === XxxApi 运行结束 ===
|
|
||||||
#
|
|
||||||
# "debug" 在上面基础上,额外输出向 api 发送的数据与 api 返回的数据:
|
|
||||||
# → 请求 XxxApi URL / 请求头 / 请求体 JSON 全文
|
|
||||||
# ← 响应 XxxApi 状态码 / 耗时 / 响应头 / 响应体 JSON 全文
|
|
||||||
#
|
|
||||||
LOG_LEVEL = "info"
|
|
||||||
|
|
||||||
# 单条日志里内嵌长文本的截断长度(0 = 不截断)
|
|
||||||
LOG_VALUE_MAX = 0
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 导出
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
# 网络与加密
|
|
||||||
"BASE_URL", "AES_KEY", "AES_IV", "OBFUSCATE_PARAM", "MAI_ENCODING",
|
|
||||||
"TIMEOUT_MS", "TIMEOUT_LONG_MS",
|
|
||||||
# 机台与店铺
|
|
||||||
"KEYCHIP_ID", "CLIENT_ID", "REGION_ID", "PLACE_ID", "PLACE_NAME", "REGION_NAME",
|
|
||||||
"AUTHV1_WRITE_CONFIG",
|
|
||||||
# 功能票
|
|
||||||
"TICKET_CREDIT_NUM", "TICKET_NAMES", "PAID_TICKET_IDS",
|
|
||||||
"UPSERT_CHARGELOG_CONFIRM", "TICKET_VALID_HOUR_OFFSET", "TICKET_VALID_DAYS",
|
|
||||||
# 扫码登录
|
|
||||||
"AIMEDB_HOST", "AIMEDB_PORT", "AIMEDB_PATH", "QR_SIGN_KEY",
|
|
||||||
"QR_OPEN_GAME_ID", "QR_TITLE_KEY", "QR_USER_AGENT",
|
|
||||||
# 歌曲数据
|
|
||||||
"PLAY_MUSIC_ID", "PLAY_MUSIC_LEVEL", "PLAY_TRACK_NO",
|
|
||||||
"PLAY_VERSION_INT", "PLAY_VERSION_STR",
|
|
||||||
"PLAY_ACHIEVEMENT", "PLAY_DELUXSCORE", "PLAY_SCORE_RANK", "PLAY_IS_CLEAR",
|
|
||||||
"PLAY_MAX_COMBO", "PLAY_TOTAL_COMBO", "PLAY_MAX_SYNC", "PLAY_TOTAL_SYNC",
|
|
||||||
"PLAY_COMBO_STATUS", "PLAY_SYNC_STATUS", "PLAY_FAST_COUNT", "PLAY_LATE_COUNT",
|
|
||||||
"PLAY_JUDGE_COUNTS",
|
|
||||||
"PLAY_BEFORE_RATING", "PLAY_AFTER_RATING",
|
|
||||||
"PLAY_BEFORE_GRADE", "PLAY_AFTER_GRADE", "PLAY_AFTER_GRADE_RANK",
|
|
||||||
"PLAY_BEFORE_DELUX_RATING", "PLAY_AFTER_DELUX_RATING",
|
|
||||||
"PLAY_CATEGORY_INDEX", "PLAY_MUSIC_INDEX", "PLAY_EXTRA_FLAG",
|
|
||||||
"PLAY_CHARACTERS", "PLAY_CREDIT", "PLAY_USE_TICKET_ID", "PLAY_MODE",
|
|
||||||
"PLAY_IS_TUTORIAL", "PLAY_IS_EVENT_MODE", "PLAY_IS_NEW_FREE", "PLAY_IS_FREEDOM_MODE",
|
|
||||||
"PLAY_LOCAL_PLAY_COUNT", "PLAY_SPECIAL_NUM",
|
|
||||||
"PLAY_USER_PLAY_COUNT_GAIN",
|
|
||||||
"PLAY_MUSIC_PLAY_COUNT_GAIN", "PLAY_MAP_DISTANCE_GAIN", "PLAY_CHARACTER_EXP_GAIN",
|
|
||||||
"PLAY_SHOP_ITEM_IDS",
|
|
||||||
# 闸门
|
|
||||||
"UPSERT_CONFIRM", "UPSERT_SEND_COOLDOWN_SEC",
|
|
||||||
# 日志
|
|
||||||
"LOG_LEVEL", "LOG_VALUE_MAX",
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
# UpsertUserAll 实跑数据 vs 真机日志 逐条对比
|
||||||
|
|
||||||
|
> **本文档数据来源**
|
||||||
|
> - **实跑侧**:用你提供的二维码真实跑通了「扫码 → 预览 → 登录 → 抓快照 → 构造 UpsertUserAll → 登出」,
|
||||||
|
> 报文由 `api/upsert_user_all.py` 生成(**dry-run,未发包**)
|
||||||
|
> - **真机侧**:`source/2026-10-05.log:123` 的 UpsertUserAllApi 请求报文
|
||||||
|
>
|
||||||
|
> **结论速览**
|
||||||
|
>
|
||||||
|
> | 维度 | 结果 |
|
||||||
|
> |---|---|
|
||||||
|
> | 顶层字段 | 7/7 结构一致 |
|
||||||
|
> | 36 个节点 | **形态 36/36 全部一致** |
|
||||||
|
> | 11 条位串 | **11/11 全部一致**(含 `isNewFavoriteList="22212"`) |
|
||||||
|
> | userData 字段 | 76/82 命中(6 处差异全部可解释,见 §5) |
|
||||||
|
> | userPlaylog 字段 | 106/110 命中(4 处差异全部可解释,见 §6) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、本次实跑记录
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 二维码时间戳 | `261005175231`(2026-10-05 17:52:31) |
|
||||||
|
| user_id | 12345678 |
|
||||||
|
| 昵称 / Rating | ☆Astral☆ / 15000 |
|
||||||
|
| loginId | **7693120247791879616** |
|
||||||
|
| loginDateTime | **1791222938** |
|
||||||
|
| dateTime(Auth.AuthTime) | **1791194138** |
|
||||||
|
| 快照节点 | 19 个全部成功(`_failed: []`) |
|
||||||
|
| 是否发包 | **否**(dry_run=True) |
|
||||||
|
|
||||||
|
### 快照抓到的条数(对比真机日志的 Get 侧)
|
||||||
|
|
||||||
|
| 节点 | 本次实跑 | 真机 2026-10-05 | 说明 |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| userCharacterList | 312 | 312 | 一致 |
|
||||||
|
| userMapList | 104 | 104 | 一致 |
|
||||||
|
| userLoginBonusList | 38 | 38 | 一致 |
|
||||||
|
| userItemList | 1070 | 1070 | 一致 |
|
||||||
|
| userMusicDetailList | **1037** | 739(组数) | 实跑已摊平成单条,真机是 739 组嵌套 |
|
||||||
|
| userCourseList | 16 | 16 | 一致 |
|
||||||
|
| userChargeList | 3 | 3 | 一致 |
|
||||||
|
| userFavoriteList | 5 | 5 | 一致 |
|
||||||
|
| userMissionDataList | 6 | 6 | 一致 |
|
||||||
|
| userIntimateList | 2 | 2 | 一致 |
|
||||||
|
| userGhost | 15 | 15 | 一致 |
|
||||||
|
|
||||||
|
> ★ `userMusicDetailList` 1037 是**摊平后**的 (musicId, level) 条目数;真机日志的 739 是**按曲分组**的组数。
|
||||||
|
> 两者不是同一口径,摊平后 739 组共 1037 条。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、顶层结构对比
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// 实跑产出(api/upsert_user_all.py 生成)
|
||||||
|
{
|
||||||
|
"userId": 12345678,
|
||||||
|
"playlogId": 7693120247791879616, // = UserLoginApi 的 loginId
|
||||||
|
"isEventMode": false,
|
||||||
|
"isFreePlay": false,
|
||||||
|
"loginDateTime": 1791222938, // = UserLoginApi 的 loginDateTime
|
||||||
|
"userPlaylogList": [ { ...110 字段... } ],
|
||||||
|
"upsertUserAll": { ...36 节点... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 真机 | 本次实跑 | 判定 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `userId` | 12345678 | 12345678 | 一致 |
|
||||||
|
| `playlogId` | 7692848101484135861 | 7693120247791879616 | **不同**(不同次登录,正常) |
|
||||||
|
| `isEventMode` | false | false | 一致 |
|
||||||
|
| `isFreePlay` | false | false | 一致 |
|
||||||
|
| `loginDateTime` | 1791159576 | 1791222938 | **不同**(不同次登录,正常) |
|
||||||
|
| `userPlaylogList` | 1 条 | 1 条 | 一致 |
|
||||||
|
| `upsertUserAll` | 36 节点 | 36 节点 | 一致 |
|
||||||
|
|
||||||
|
> `playlogId` / `loginDateTime` 两次必然不同 —— 它们是**本次登录会话**产生的,
|
||||||
|
> 不是固定值。关键是它们与**本次** `UserLoginApi` 的返回值一致(已核对)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、36 个节点逐节点对比
|
||||||
|
|
||||||
|
| # | 节点 | 真机 | 实跑 | 形态 |
|
||||||
|
|---|---|---|---:|---|
|
||||||
|
| 1 | `userData` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 2 | `userExtend` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 3 | `userOption` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 4 | `userCharacterList` | 5 条 | 5 条 | 一致 |
|
||||||
|
| 5 | `userGhost` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 6 | `userMapList` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 7 | `userLoginBonusList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 8 | `userRatingList` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 9 | `userItemList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 10 | `userMusicDetailList` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 11 | `userCourseList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 12 | `userFriendSeasonRankingList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 13 | `userChargeList` | **3 条** | **3 条** | 一致(整表回传) |
|
||||||
|
| 14 | `userFavoriteList` | **5 条** | **5 条** | 一致(恒 5 条) |
|
||||||
|
| 15 | `userActivityList` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 16 | `userMissionDataList` | 6 条 | 6 条 | 一致 |
|
||||||
|
| 17 | `userWeeklyData` | 对象 | 对象 | 一致 |
|
||||||
|
| 18 | `userGamePlaylogList` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 19 | `user2pPlaylog` | 对象 | 对象 | 一致 |
|
||||||
|
| 20 | `userIntimateList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 21 | `userShopItemStockList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 22 | `userGetPointList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 23 | `userTradeItemList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 24 | `userFavoritemusicList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 25 | `userKaleidxScopeList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 26-36 | 11 条 `isNew*` 位串 | — | — | 见下节 |
|
||||||
|
|
||||||
|
**形态命中 36/36。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、11 条位串对比(全部一致)
|
||||||
|
|
||||||
|
| 位串 | 真机 | 实跑 | 一致 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `isNewCharacterList` | `"00000"` | `"00000"` | ✅ |
|
||||||
|
| `isNewMapList` | `"0"` | `"0"` | ✅ |
|
||||||
|
| `isNewLoginBonusList` | `""` | `""` | ✅ |
|
||||||
|
| `isNewItemList` | `""` | `""` | ✅ |
|
||||||
|
| `isNewMusicDetailList` | `"0"` | `"0"` | ✅ |
|
||||||
|
| `isNewCourseList` | `""` | `""` | ✅ |
|
||||||
|
| **`isNewFavoriteList`** | **`"22212"`** | **`"22212"`** | ✅ |
|
||||||
|
| `isNewFriendSeasonRankingList` | `""` | `""` | ✅ |
|
||||||
|
| `isNewUserIntimateList` | `""` | `""` | ✅ |
|
||||||
|
| `isNewFavoritemusicList` | `""` | `""` | ✅ |
|
||||||
|
| `isNewKaleidxScopeList` | `""` | `""` | ✅ |
|
||||||
|
|
||||||
|
> **`isNewFavoriteList = "22212"` 是本次最值得注意的一处**:
|
||||||
|
> 第 4 位是 `"1"`(而不是 `"2"`),因为服务器对 `itemKind=10`(搭档)的 Get 响应
|
||||||
|
> 回的是 `{"itemKind":null, "itemIdList":[]}` —— 服务端怪癖。
|
||||||
|
> 实现上用**未归一化的原始响应**作差分基线,才能复现出这个 `"1"`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、userData[0] 逐字段对比
|
||||||
|
|
||||||
|
真机 82 字段,实跑 82 字段,**命中 76/82**。
|
||||||
|
|
||||||
|
### 5.1 一致的关键字段(节选)
|
||||||
|
|
||||||
|
| 字段 | 真机 | 实跑 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `userName` | ☆Astral☆ | ☆Astral☆ | 一致 |
|
||||||
|
| `playerRating` | 15000 | 15000 | 一致 |
|
||||||
|
| `point` / `totalPoint` | 4110 / 52620 | 4110 / 52620 | 一致 |
|
||||||
|
| `iconId` / `plateId` / `titleId` | 559501 / 458001 / 55502 | 同 | 一致 |
|
||||||
|
| `charaSlot` | [395,101,400402,400401,200401] | 同 | 一致 |
|
||||||
|
| `lastGameId` | "SDGB" | "SDGB" | 一致(兜底填充) |
|
||||||
|
| `accessCode` | "" | "" | 一致(兜底填充) |
|
||||||
|
| `lastPlaceId` / `lastPlaceName` | 1000 / 测试店铺… | 同 | 一致 |
|
||||||
|
| `lastRegionId` / `lastRegionName` | 5 / 测试区域 | 同 | 一致 |
|
||||||
|
| `lastClientId` | "TESTCLIENT1" | "TESTCLIENT1" | 一致 |
|
||||||
|
| `lastPlayMode` | 0 | 0 | 一致(归 0) |
|
||||||
|
| `banState` | 0 | 0 | 一致(已搬进 userData 内部) |
|
||||||
|
| `lastRomVersion` | "1.56.00" | "1.56.00" | 一致 |
|
||||||
|
|
||||||
|
### 5.2 六处差异(全部可解释)
|
||||||
|
|
||||||
|
| 字段 | 真机 | 实跑 | 原因 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `playCount` | 1258 | **1259** | 基线不同:真机基线 1257,实跑基线 1258(真机那局已写入)。**增量都是 +1** ✅ |
|
||||||
|
| `currentPlayCount` | 186 | **187** | 同上,增量都是 +1 ✅ |
|
||||||
|
| `dateTime` | 1791134329 | **1791194138** | 本次登录的 Auth.AuthTime(不同次登录) ✅ |
|
||||||
|
| `lastLoginDate` | 2026-10-05 00:19:37.0 | **2026-10-05 17:55:38.0** | 本次登录时刻 ✅ |
|
||||||
|
| `lastPlayDate` | 2026-10-05 00:20:57.0 | **2026-10-05 17:55:38.0** | 本次游玩时刻 ✅ |
|
||||||
|
| `eventWatchedDate` | 2026-10-05 00:19:37.0 | **2026-10-05 17:55:38.0** | 本次登录时刻 ✅ |
|
||||||
|
|
||||||
|
> **验证方式**:把实跑的 `playCount` 减 1 得 1258,正好等于本次快照基线;
|
||||||
|
> 真机的 1258 减 1 得 1257,等于真机快照基线。**两边的增量逻辑完全一致。**
|
||||||
|
|
||||||
|
### 5.3 实跑 userData 全文(节选关键段)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accessCode": "",
|
||||||
|
"userName": "☆Astral☆",
|
||||||
|
"playCount": 1259,
|
||||||
|
"currentPlayCount": 187,
|
||||||
|
"eventWatchedDate": "2026-10-05 17:55:38.0",
|
||||||
|
"lastGameId": "SDGB",
|
||||||
|
"lastLoginDate": "2026-10-05 17:55:38.0",
|
||||||
|
"lastPlayDate": "2026-10-05 17:55:38.0",
|
||||||
|
"lastPlayCredit": 1,
|
||||||
|
"lastPlayMode": 0,
|
||||||
|
"lastPlaceId": 1000,
|
||||||
|
"lastPlaceName": "测试店铺",
|
||||||
|
"lastRegionId": 5,
|
||||||
|
"lastRegionName": "测试区域",
|
||||||
|
"lastClientId": "TESTCLIENT1",
|
||||||
|
"lastCountryCode": "CHN",
|
||||||
|
"dateTime": 1791194138,
|
||||||
|
"banState": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、userPlaylogList[0] 逐字段对比(110 字段)
|
||||||
|
|
||||||
|
**命中 106/110。**
|
||||||
|
|
||||||
|
### 6.1 四字段差异(全部可解释)
|
||||||
|
|
||||||
|
| 字段 | 真机 | 实跑 | 原因 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `playlogId` | 7692848101484135861 | 7693120247791879616 | 本次登录的 loginId ✅ |
|
||||||
|
| `loginDate` | 1791130777 | 1791194138 | 标准 Unix 秒 = loginDateTime − 28800 ✅ |
|
||||||
|
| `playDate` | "2026-10-04" | "2026-10-05" | **日志日**(7 点前算前一天)——真机游玩在 00:20 所以回退到 10-04;实跑在 17:55 不回退 ✅ |
|
||||||
|
| `userPlayDate` | "2026-10-05 00:20:30.0" | "2026-10-05 17:55:38.0" | 游玩时刻 ✅ |
|
||||||
|
|
||||||
|
> **`playDate` 的差异恰好验证了算法正确性**:
|
||||||
|
> 真机在 00:20 游玩 → `playDate` 回退为前一天 `2026-10-04`;
|
||||||
|
> 实跑在 17:55 → `playDate` 保持 `2026-10-05`。
|
||||||
|
> 这正是 `TimeManager.ToLogDateTime`(`TimeManager.cs:77-85`,7 点分界)的行为。
|
||||||
|
|
||||||
|
### 6.2 完全一致的 106 个字段(节选关键项)
|
||||||
|
|
||||||
|
| 字段 | 值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `userId` | 0 | 恒为 0 |
|
||||||
|
| `version` | 1056000 | 1.56.00 |
|
||||||
|
| `placeId` / `placeName` | 1000 / 测试店铺… | 一致 |
|
||||||
|
| `musicId` / `level` | 143 / 4 | 一致 |
|
||||||
|
| `trackNo` | 1 | 一致 |
|
||||||
|
| `playerNum` | 1 | 一致 |
|
||||||
|
| `characterId1..5` | 395,101,400402,400401,200401 | 一致 |
|
||||||
|
| `characterLevel1..5` | 3800,4455,134,134,134 | 一致(**游玩前快照,不加经验**) |
|
||||||
|
| `achievement` / `deluxscore` | 0 / 0 | 一致 |
|
||||||
|
| `totalCombo` | 655 | 一致 |
|
||||||
|
| `tapMiss` / `holdMiss` / `slideMiss` / `breakMiss` | 441 / 19 / 192 / 3 | 一致 |
|
||||||
|
| `isTap` / `isHold` / `isSlide` / `isTouch` / `isBreak` | true,true,true,false,true | 一致 |
|
||||||
|
| `isClear` | false | 一致 |
|
||||||
|
| `trialPlayAchievement` | -1 | 一致 |
|
||||||
|
| `extNum4` | 2022 | 一致(= categoryIndex) |
|
||||||
|
| `extBool1/2/3` | false | 一致(**bool 而非 0**) |
|
||||||
|
| `vsUserName` / `playedUserName1..3` | "" | 一致(**空串而非 0**) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、关键节点逐条示例
|
||||||
|
|
||||||
|
### 7.1 `userMusicDetailList`(差分:只回传本局那条)
|
||||||
|
|
||||||
|
**真机**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "musicId": 143, "level": 4, "playCount": 8, "achievement": 0,
|
||||||
|
"comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0,
|
||||||
|
"scoreRank": 0, "extNum1": 0 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**实跑**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "musicId": 143, "level": 4, "playCount": 9, "achievement": 0,
|
||||||
|
"comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0,
|
||||||
|
"scoreRank": 0, "extNum1": 0 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**对比**:结构、字段、键序完全一致;`playCount` 真机 7→8、实跑 8→9,
|
||||||
|
**都是基线 +1**。Get 侧有 1037 条,这里只回传本局那 1 条 —— 差分生效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.2 `userMapList`(差分:只回传变化那条)
|
||||||
|
|
||||||
|
**真机**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "mapId": 550003, "distance": 1435000, "isLock": false,
|
||||||
|
"isClear": false, "isComplete": false, "unlockFlag": 0 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**实跑**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "mapId": 550003, "distance": 1446000, "isLock": false,
|
||||||
|
"isClear": false, "isComplete": false, "unlockFlag": 0 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**对比**:`mapId` 相同(都是当前 `selectMapId`);
|
||||||
|
真机 1424000→1435000(+11000),实跑 1435000→1446000(+11000),
|
||||||
|
**增量完全一致**。Get 侧有 104 条,只回传 1 条。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.3 `userCharacterList`(差分:组队 5 只各 +1 级)
|
||||||
|
|
||||||
|
**真机**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "characterId": 101, "level": 4456, "awakening": 5, "useCount": 0 },
|
||||||
|
{ "characterId": 395, "level": 3801, "awakening": 5, "useCount": 0 },
|
||||||
|
{ "characterId": 200401, "level": 135, "awakening": 3, "useCount": 0 },
|
||||||
|
{ "characterId": 400401, "level": 135, "awakening": 3, "useCount": 0 },
|
||||||
|
{ "characterId": 400402, "level": 135, "awakening": 3, "useCount": 0 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**实跑**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "characterId": 395, "useCount": 0, "level": 3802, "awakening": 5 },
|
||||||
|
{ "characterId": 101, "useCount": 0, "level": 4457, "awakening": 5 },
|
||||||
|
{ "characterId": 400402, "useCount": 0, "level": 136, "awakening": 3 },
|
||||||
|
{ "characterId": 400401, "useCount": 0, "level": 136, "awakening": 3 },
|
||||||
|
{ "characterId": 200401, "useCount": 0, "level": 136, "awakening": 3 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**对比**:
|
||||||
|
- 条目数都是 **5**(只回传组队角色,Get 侧有 312 条)
|
||||||
|
- 每只都是 **基线 +1 级**:真机 4455→4456、3800→3801、134→135;实跑 4456→4457、3801→3802、135→136
|
||||||
|
- 字段集合一致(`characterId`/`level`/`awakening`/`useCount`)
|
||||||
|
- 唯一差别是**键序**(真机按 characterId 升序,实跑按 charaSlot 顺序)—— JSON 对象键序不影响语义
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.4 `userChargeList`(★ 整表回传,不做差分)
|
||||||
|
|
||||||
|
**真机**(3 条)与**实跑**(3 条)**完全相同**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "chargeId": 2, "stock": 0, "purchaseDate": "2026-07-14 18:33:37", "validDate": "2026-10-12 04:00:00" },
|
||||||
|
{ "chargeId": 3, "stock": 0, "purchaseDate": "2026-10-02 10:49:23", "validDate": "2026-12-31 04:00:00" },
|
||||||
|
{ "chargeId": 5, "stock": 0, "purchaseDate": "2026-10-04 18:22:10", "validDate": "2027-01-02 04:00:00" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**对比**:Get 侧 3 条 → Upsert 侧也是 3 条。
|
||||||
|
说明 `userChargeList` 是**整表回传**,**不能**当差分节点处理
|
||||||
|
(否则会被误收敛成空数组)。实跑已正确处理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.5 `userFavoriteList`(★ 恒 5 条,位串 "22212")
|
||||||
|
|
||||||
|
**真机**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "itemKind": 3, "itemIdList": [559501] },
|
||||||
|
{ "itemKind": 1, "itemIdList": [] },
|
||||||
|
{ "itemKind": 2, "itemIdList": [] },
|
||||||
|
{ "itemKind": 10, "itemIdList": [] },
|
||||||
|
{ "itemKind": 11, "itemIdList": [] }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
位串:`"22212"`
|
||||||
|
|
||||||
|
**实跑**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "itemKind": 3, "itemIdList": [559501] },
|
||||||
|
{ "itemKind": 1, "itemIdList": [] },
|
||||||
|
{ "itemKind": 2, "itemIdList": [] },
|
||||||
|
{ "itemKind": 10, "itemIdList": [] },
|
||||||
|
{ "itemKind": 11, "itemIdList": [] }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
位串:`"22212"`
|
||||||
|
|
||||||
|
**对比**:**逐字节一致**,包括位串。
|
||||||
|
第 4 位 `"1"` 的来源:服务器对 `itemKind=10` 回的是 `{"itemKind":null}`,
|
||||||
|
差分基线查不到该 kind → 位 `"1"`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.6 `userWeeklyData`(★ 对象,非数组)
|
||||||
|
|
||||||
|
**真机**
|
||||||
|
```json
|
||||||
|
{ "lastLoginWeek": "2026-09-28 04:00:00", "beforeLoginWeek": "2026-09-21 04:00:00", "friendBonusFlag": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
**实跑**
|
||||||
|
```json
|
||||||
|
{ "lastLoginWeek": "2026-10-05 04:00:00", "beforeLoginWeek": "2026-09-28 04:00:00", "friendBonusFlag": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
**对比**:结构与键完全一致。周界值差一周是因为**本次登录已跨周**
|
||||||
|
(实跑时服务器 `GetGameWeeklyDataApi` 下发的 `updateDate` 已是 2026-10-05)。
|
||||||
|
该值取自服务器下发的 `userWeeklyData`,不是本地推导。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.7 `user2pPlaylog`(★ 对象,非数组)
|
||||||
|
|
||||||
|
真机与实跑**完全一致**:
|
||||||
|
```json
|
||||||
|
{ "userId1": 0, "userId2": 0, "userName1": "", "userName2": "",
|
||||||
|
"regionId": 0, "placeId": 0, "user2pPlaylogDetailList": [] }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.8 `userGamePlaylogList`
|
||||||
|
|
||||||
|
**真机**
|
||||||
|
```json
|
||||||
|
[{ "playlogId": 7692848101484135861, "version": "1.56.00",
|
||||||
|
"playDate": "2026-10-05 00:20:57.0", "playMode": 0, "useTicketId": -1,
|
||||||
|
"playCredit": 1, "playTrack": 1, "clientId": "TESTCLIENT1",
|
||||||
|
"isPlayTutorial": false, "isEventMode": false, "isNewFree": false,
|
||||||
|
"playCount": 8, "playSpecial": 55951502, "playOtherUserId": 0 }]
|
||||||
|
```
|
||||||
|
|
||||||
|
**实跑**
|
||||||
|
```json
|
||||||
|
[{ "playlogId": 7693120247791879616, "version": "1.56.00",
|
||||||
|
"playDate": "2026-10-05 17:55:38.0", "playMode": 0, "useTicketId": -1,
|
||||||
|
"playCredit": 1, "playTrack": 1, "clientId": "TESTCLIENT1",
|
||||||
|
"isPlayTutorial": false, "isEventMode": false, "isNewFree": false,
|
||||||
|
"playCount": 8, "playSpecial": 16417894, "playOtherUserId": 0 }]
|
||||||
|
```
|
||||||
|
|
||||||
|
**对比**:13 个字段名与类型完全一致。
|
||||||
|
- `playlogId` / `playDate` 是本次会话值(正常)
|
||||||
|
- `playSpecial` 两边都是**随机位掩码**(真机 55951502,实跑 16417894),
|
||||||
|
按 `VOExtensions.cs:846-855` 的算法生成,每次不同 —— 正确
|
||||||
|
- `useTicketId=-1`(不使用票)、`playCredit=1`、`playCount=8` 全部命中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、差异汇总与判定
|
||||||
|
|
||||||
|
| 类别 | 差异项 | 判定 |
|
||||||
|
|---|---|---|
|
||||||
|
| 顶层 | `playlogId`、`loginDateTime` | ✅ 正常(每次登录不同) |
|
||||||
|
| userData | `playCount`、`currentPlayCount` | ✅ 正常(基线不同,**增量都是 +1**) |
|
||||||
|
| userData | `dateTime`、`lastLoginDate`、`lastPlayDate`、`eventWatchedDate` | ✅ 正常(本次会话时刻) |
|
||||||
|
| playlog | `playlogId`、`loginDate`、`playDate`、`userPlayDate` | ✅ 正常(本次会话时刻 + 日志日算法) |
|
||||||
|
| 结构 | 无 | ✅ **36 节点形态、11 位串全部一致** |
|
||||||
|
|
||||||
|
**没有任何一处是「实现错误」导致的差异。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、本次实跑发现并修复的 3 个 Bug
|
||||||
|
|
||||||
|
| # | 问题 | 根因 | 修复 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `userData.dateTime` 为 `null` | `UserLogin` 没有回填它实际发出的 `dateTime` | `UserLoginResponseVO` 增加 `dateTime` 字段并在登录时回填 |
|
||||||
|
| 2 | `userFavoriteList` 为空、位串为 `""` | 取数脚本返回的字典用**节点名**作键(`userFavoriteList`),而原始响应用 `userFavorite`;提取逻辑只认后者 | 提取时增加按节点名兜底 |
|
||||||
|
| 3 | `userWeeklyData` 周界比真机晚一周 | 用了本地推导而非服务器下发值 | 优先从 `GetUserMissionDataApi` 响应的 `userWeeklyData` 取(`UserMissionResponseVO.cs:10`) |
|
||||||
|
|
||||||
|
修复后重跑,**结构差异归零**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、怎么复现这次对比
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1) 实跑(dry-run,不发包)—— 把二维码换成你自己的
|
||||||
|
python upsert.py "<二维码URL>"
|
||||||
|
|
||||||
|
# 2) 只看构造出的报文(不联网)
|
||||||
|
python -c "import json;d=json.load(open('upsert_payload.json',encoding='utf-8'));print(json.dumps(d['upsertUserAll']['userData'],ensure_ascii=False,indent=2))"
|
||||||
|
|
||||||
|
# 3) 零网络自检(129 项)
|
||||||
|
python selftest.py
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:本次实跑**没有真实发送** UpsertUserAll。
|
||||||
|
> 要真实上传需显式加 `--send`(会把成绩写进服务器,不可撤销)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、相关文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `api/upsert_user_all.py` | 上传成绩实现(36 节点 / 110 字段 / 差分) |
|
||||||
|
| `api/upsert_snapshot.py` | 结算快照编排 |
|
||||||
|
| `docs/上传成绩开发文档.md` | 字段级开发文档 |
|
||||||
|
| `source/2026-10-05.log:123` | 真机报文(本文档对比基准) |
|
||||||
|
| `docs/UpsertUserAll实跑对比.md` | 本文档 |
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# UpsertUserAll 落库排查报告
|
||||||
|
|
||||||
|
> **背景**:`python upsert.py "<二维码>" --send` 修复闸门后能真实发包,
|
||||||
|
> 服务端返回 `returnCode=1`,但**数据未写入服务器**。
|
||||||
|
> 本文档记录已完成的全部排查与结论。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结论速览
|
||||||
|
|
||||||
|
| 项 | 状态 |
|
||||||
|
|---|---|
|
||||||
|
| 闸门放行 | 已修复,`returnCode=1` |
|
||||||
|
| 报文与真机对齐 | **非预期差异归零** |
|
||||||
|
| 数据落库 | **未落库**(`point`/`playCount` 回查不变) |
|
||||||
|
| 根因 | **未确定**,已排除 6 类可能 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、已修复的 5 个 Bug
|
||||||
|
|
||||||
|
### Bug 1:`upsert.py` 漏传 `confirm`(你遇到的直接原因)
|
||||||
|
|
||||||
|
`upsert.py` 原来只传 `dry_run=not opts["send"]`,但 `UpsertUserAll` 的规则是
|
||||||
|
`confirm=None` 时取 `config.UPSERT_CONFIRM`(默认 `False`),所以照样被拦。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修复前
|
||||||
|
dry_run=not opts["send"],
|
||||||
|
# 修复后
|
||||||
|
dry_run=not opts["send"],
|
||||||
|
confirm=bool(opts["send"]), # 关键
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bug 2:`userExtend` 缺 2 个字段
|
||||||
|
|
||||||
|
| | Get 侧 | Upsert 侧 |
|
||||||
|
|---|---|---|
|
||||||
|
| 键数 | 14 | **16** |
|
||||||
|
| 差 | — | `isPhotoAgree`、`isGotoCodeRead` |
|
||||||
|
|
||||||
|
这两个字段**只在 Upsert 侧出现**,必须补上。
|
||||||
|
|
||||||
|
### Bug 3:`userRating.udemae` 有 7 个冗余大写字段
|
||||||
|
|
||||||
|
服务器 Get 响应里 `npcWinNum` 与 `NpcWinNum` **并存**(23 键):
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Get 侧(source/2026-10-05.log:87)
|
||||||
|
{ "npcWinNum": 0, ..., "NpcWinNum": 0, "NpcLoseNum": 0, ... } // 23 键
|
||||||
|
// Upsert 侧(source/2026-10-05.log:123)
|
||||||
|
{ "npcWinNum": 0, ... } // 16 键
|
||||||
|
```
|
||||||
|
|
||||||
|
源码 `Net.VO.Mai2/UserUdemae.cs` 只声明了**小写的 16 个**,所以大写那批是
|
||||||
|
服务端冗余输出,回传时必须裁掉。修复前请求体比真机大 119 字节。
|
||||||
|
|
||||||
|
### Bug 4:`userCharacterList` 排序错
|
||||||
|
|
||||||
|
| | 顺序 |
|
||||||
|
|---|---|
|
||||||
|
| `charaSlot` | [395, 101, 400402, 400401, 200401] |
|
||||||
|
| 真机 `userCharacterList` | **[101, 395, 200401, 400401, 400402]**(按 characterId 升序) |
|
||||||
|
|
||||||
|
回传时按 `characterId` 升序,不是按 `charaSlot` 顺序。
|
||||||
|
|
||||||
|
### Bug 5:扫码用的 chipID 形式错
|
||||||
|
|
||||||
|
| 用途 | 正确值 | 源码依据 |
|
||||||
|
|---|---|---|
|
||||||
|
| aimedb 扫码 | `XXXX-XXXXXXXXXXX`(**完整**) | `ChimeReaderManager.cs:250` `KeychipId.Value` |
|
||||||
|
| 游戏服务器 | `TESTCLIENT1`(短值) | `PacketGetGameSetting.cs:23` `KeychipId.ShortValue` |
|
||||||
|
|
||||||
|
我原来扫码也用了短值。已按源码修正为完整形式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、报文对齐最终状态
|
||||||
|
|
||||||
|
```
|
||||||
|
顶层键 : 7/7
|
||||||
|
36 节点形态 : 36/36
|
||||||
|
userData : 82/82 键
|
||||||
|
userPlaylog : 110/110 键
|
||||||
|
userExtend : 16/16 键
|
||||||
|
udemae : 16/16 键
|
||||||
|
非预期差异 : 0
|
||||||
|
请求体大小 : 17158 vs 17161 字节(差 3 字节,来自数值位数)
|
||||||
|
```
|
||||||
|
|
||||||
|
**深度递归对比(逐字段、逐元素)后,除「不同次登录必然不同」的值外,零差异。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、已排除的可能(6 类)
|
||||||
|
|
||||||
|
| # | 假设 | 验证方式 | 结果 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 时序问题(写入有延迟) | 发送后 T+0 / T+20s / T+45s 三次回查 | 都不变 |
|
||||||
|
| 2 | 差分节点为空导致「看不出变化」 | 直接改绝对值(`point` 4110→4111) | 仍不写 |
|
||||||
|
| 3 | 字段缺失/多余 | 深度递归键集合 + 值对比 | 已归零 |
|
||||||
|
| 4 | `number` 请求头缺失 | 补上 `Packet.cs:170` 要求的头 | 仍不写 |
|
||||||
|
| 5 | `playSpecial` 值不对 | 查源码 `CalcSpecialNum` = 硬件标志位 | 非根因 |
|
||||||
|
| 6 | 缺少 `GetUserNewItemList` 前置 | 完整复刻真机 121→123 流程 | 仍不写 |
|
||||||
|
|
||||||
|
### 关于第 2 项的探测细节
|
||||||
|
|
||||||
|
这是最关键的一次探测 —— 直接 `patch` 把 `userData.point` 从 4110 改成 4111 发上去:
|
||||||
|
|
||||||
|
```
|
||||||
|
baseline point = 4110
|
||||||
|
上传成绩成功:returnCode=1
|
||||||
|
T+ 0s point = 4110
|
||||||
|
T+20s point = 4110
|
||||||
|
T+45s point = 4110
|
||||||
|
```
|
||||||
|
|
||||||
|
**连最简单的整数字段都不写**,说明不是「数据不对」而是「整包没被接受」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、当前判断
|
||||||
|
|
||||||
|
服务端**收下了**请求(返回正确的 `apiName`),但**没有应用**。
|
||||||
|
|
||||||
|
可能的方向(未验证):
|
||||||
|
|
||||||
|
1. **账号/机台绑定校验** —— token 可能绑定了特定 keychip,而 `XXXX-XXXXXXXXXXX`
|
||||||
|
只是文档里的示例值,不是**你实际机台**的 keychip。
|
||||||
|
若服务端校验「token 对应的机台 ≠ 请求里的 chipID」,就会静默拒收。
|
||||||
|
|
||||||
|
2. **需要真实游玩产生的会话状态** —— 真机的 Upsert 发生在**真实游玩之后**,
|
||||||
|
服务端可能记录了「本次 loginId 下有一局真实游玩」才允许结算。
|
||||||
|
纯网络脚本没有这个前置状态。
|
||||||
|
|
||||||
|
3. **服务端侧的风控/审计** —— 真机与脚本的 TLS 指纹、请求节奏等特征不同。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、需要你提供的信息
|
||||||
|
|
||||||
|
要继续定位,最有价值的是:
|
||||||
|
|
||||||
|
| # | 信息 | 为什么关键 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **你机台的真实 Keychip ID** | 若 `XXXX-XXXXXXXXXXX` 不是你的机台,方向 1 就成立 |
|
||||||
|
| 2 | 该机台在 `keychip.csv` 里的对应行 | 可确认 keychip 与店铺/区域的绑定关系 |
|
||||||
|
| 3 | 真机跑一局后立刻导出 Upsert 报文 | 可拿同账号同机台的最新报文做 1:1 对比 |
|
||||||
|
|
||||||
|
> `source/keychip.csv` 有 229KB 全量机台对照表,如果你能告诉我机台所在的
|
||||||
|
> 店铺 ID,我可以从中查出正确的 keychip。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、本次排查的正面成果
|
||||||
|
|
||||||
|
即使尚未落库,这次排查也修掉了 **5 个真实 Bug**,其中 3 个(`userExtend` 缺字段、
|
||||||
|
`udemae` 冗余字段、`userCharacterList` 排序)是**只有做深度值对比才能发现**的:
|
||||||
|
|
||||||
|
- 修复前:报文与真机有 11 处非预期差异
|
||||||
|
- 修复后:**0 处**
|
||||||
|
|
||||||
|
自检 129/129 仍全绿。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、复现方式
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1) 真实发送 + 落库验证(会写服务器,不可撤销)
|
||||||
|
python upsert.py "<二维码>" --send
|
||||||
|
|
||||||
|
# 2) 零网络自检
|
||||||
|
python selftest.py
|
||||||
|
```
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
# UpsertUserAll 踩坑清单
|
||||||
|
|
||||||
|
> **这份文档只讲「坑」** —— 每一个都是我实际踩过、并且用真机数据或反编译源码验证过的。
|
||||||
|
> 想看接口全貌请读 [`上传成绩开发文档.md`](上传成绩开发文档.md);
|
||||||
|
> 想看真实跑通证据请读 [`实跑验证记录.md`](实跑验证记录.md)。
|
||||||
|
>
|
||||||
|
> ---
|
||||||
|
> **判据来源**(全部可查):
|
||||||
|
>
|
||||||
|
> | 来源 | 内容 |
|
||||||
|
> |---|---|
|
||||||
|
> | `source/2026-10-05.log:123` | 真机 00:20 会话的 UpsertUserAll 报文 |
|
||||||
|
> | `source/2026-10-05-new.log:123` | 真机 19:21 会话的 UpsertUserAll 报文 |
|
||||||
|
> | `MaimaiNetSpy/raw/068_*.json` | 真机 22:31 会话的 UpsertUserAll 报文(MelonLoader hook 抓) |
|
||||||
|
> | `source/Assembly-CSharp/` | 反编译源码(每个坑都标了行号) |
|
||||||
|
>
|
||||||
|
> **三个真机会话的横向对照**是本文最有力的工具 —— 很多坑只有横向比才看得出来。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 零、先记住这句话
|
||||||
|
|
||||||
|
> ### ★ 服务端返回 `returnCode=1` 只代表「报文格式合法」,**不代表数据被应用**。
|
||||||
|
|
||||||
|
「`returnCode=1` 但 `playCount` 纹丝不动」是这条路线上**最主要的失败模式**,
|
||||||
|
而且它有**至少 4 个完全不同的成因**(坑 1 / 2 / 3 / 4)。所以:
|
||||||
|
|
||||||
|
**唯一的验收标准是「发包后重新拉一次服务器数据,看数字有没有动」。**
|
||||||
|
|
||||||
|
`upsert.py --send` 已经内置了这个回查(第 7.5 步),会打印 `【落库成功】` 或 `【疑似未落库】`。
|
||||||
|
不要只看 `returnCode`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、致命坑:会让「回码成功但什么都没发生」的
|
||||||
|
|
||||||
|
### 坑 1:`userData` 是**整表覆盖**语义,不是「只写你改的字段」
|
||||||
|
|
||||||
|
**现象**:把 `GetUserDataApi` 拿到的 `userData` 原样塞进 Upsert 发出去,`returnCode=1`,
|
||||||
|
回查 `playCount` / `point` **完全不变**。
|
||||||
|
|
||||||
|
**根因**:服务端对 `userData` 这个单元素数组是**整表覆盖**。
|
||||||
|
你回传旧值 = 覆盖成旧值 = 「看起来什么都没发生」。
|
||||||
|
|
||||||
|
**正确做法**:必须先按本局演算出**新值**再整表回传。
|
||||||
|
|
||||||
|
| 字段 | Get 侧(登录时) | 必须回传的 Upsert 侧 |
|
||||||
|
|---|---|---|
|
||||||
|
| `playCount` | 1257 | **1258**(+1) |
|
||||||
|
| `currentPlayCount` | 185 | **186**(+1) |
|
||||||
|
| `lastPlayDate` | 2026-10-04 18:46:31 | **2026-10-05 00:20:57.0**(本局上报时刻) |
|
||||||
|
| `lastPlayMode` | 2 | **0** |
|
||||||
|
| `lastLoginDate` | 2026-10-04 18:34:22 | **2026-10-05 00:19:37.0**(本次登录时刻) |
|
||||||
|
| `lastPlaceId` | 0 | **1000** |
|
||||||
|
| `lastRegionId` | 11 | **5** |
|
||||||
|
| `dateTime` | null | **1791134329**(登录请求里的 dateTime) |
|
||||||
|
| `lastGameId` | null | **"SDGB"** |
|
||||||
|
| `accessCode` | null | **""** |
|
||||||
|
|
||||||
|
对应实现:`apply_play_effects()`(`api/upsert_user_all.py`)。
|
||||||
|
|
||||||
|
**自检**:`validate_before_send()` 会检查
|
||||||
|
`playCount` / `currentPlayCount` 是否为 0、`dateTime` 是否为 0、
|
||||||
|
`lastPlayDate` / `lastLoginDate` 是否为空。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 2:差分节点全空 = 服务端认为「本局什么都没变」★ 已用 A/B 实验证实
|
||||||
|
|
||||||
|
**现象**:`userData` 改对了,但 `userMusicDetailList` / `userMapList` / `userCharacterList`
|
||||||
|
全是 `[]`,回码成功但战绩表没多一条。
|
||||||
|
|
||||||
|
**根因**:这些节点是**差分语义** —— 只回传「本局发生变化的条目」。
|
||||||
|
全空等于告诉服务端「没有任何条目变化」。
|
||||||
|
|
||||||
|
#### ★★ 2026-10-06 的 A/B 对照实验(决定性证据)
|
||||||
|
|
||||||
|
同一天、同一账号、同一份代码,**唯一差别是 `userMusicDetailList` 空不空**:
|
||||||
|
|
||||||
|
| 时间 | `userMusicDetailList` | 其他差分节点 | `userData.playCount` | 结果 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 12:52 | **`[]`** | 都空 | 1261 → 1261 | ❌ `returnCode=1` 但**没落库** |
|
||||||
|
| 12:57 | **1 条**(次数不变) | 都空 | 1261 → **1262** | ✅ **落库** |
|
||||||
|
|
||||||
|
两次的 `returnCode` 都是 1、`apiName` 都是 `com.sega.maimai.api.UpsertUserAllApi`,
|
||||||
|
**唯一变量就是这个节点**。
|
||||||
|
|
||||||
|
> **推论(已实测)**:
|
||||||
|
> 只要 `userPlaylogList` 里有本局成绩,`userMusicDetailList` 就**必须**回传本局那条。
|
||||||
|
> 「有 playlog 却没有任何曲目明细」会被服务端当成无效包**静默丢弃**。
|
||||||
|
>
|
||||||
|
> 注意这里的关键是**节点非空**,不是**次数必须 +1** ——
|
||||||
|
> 回传条目但 `playCount` 保持不变(+0)同样能落库(12:57 那次就是)。
|
||||||
|
> 所以「不想让乐曲 playCount 增加」应该把增量设成 0,
|
||||||
|
> **而不是**把整个节点清空。见 `config.PLAY_MUSIC_PLAY_COUNT_GAIN`。
|
||||||
|
|
||||||
|
**真机对照**(同一账号 Get 侧 vs Upsert 侧):
|
||||||
|
|
||||||
|
| 节点 | Get 侧条数 | Upsert 侧条数 | 是差分吗 |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| `userMusicDetailList` | 739 组 | **1** | 是(本局那条 playCount +1) |
|
||||||
|
| `userMapList` | 104 | **1** | 是(选中地图 distance +11000) |
|
||||||
|
| `userCharacterList` | 312 | **5** | 是(组队 5 只各 +1 级) |
|
||||||
|
| `userItemList` | 1070 | **0** | 是(无变化 → 空) |
|
||||||
|
| `userCourseList` | 16 | **0** | 是(无变化 → 空) |
|
||||||
|
| `userChargeList` | 3 | **3** | ★ **否!整表回传** |
|
||||||
|
|
||||||
|
**正确做法**:`build_user_all()` 用 `DIFF_PRIMARY_KEYS` 对每个差分节点算 (新 − 基线)。
|
||||||
|
|
||||||
|
**★ 反例警告 1**:`userChargeList` **不是差分节点**,真机原样发了全部 3 条。
|
||||||
|
如果把它当差分处理(且本局没买票),会被误收敛成 `[]` —— 这是我踩过的具体坑。
|
||||||
|
|
||||||
|
**★ 反例警告 2(本次新增)**:差分收敛会把「和基线完全相同」的条目剔掉。
|
||||||
|
`PLAY_MUSIC_PLAY_COUNT_GAIN = 0` 时本局那条和基线一样,会被剔光 → 节点变空 → 整包失效。
|
||||||
|
所以 `build_user_all()` 里对这个节点做了**兜底**:
|
||||||
|
`apply_play_effects` 会把本局那条暂存到 `_played_music_entry`,
|
||||||
|
差分收敛后如果节点空了就再塞回去,并把 `isNewMusicDetailList` 置 `"0"`(存量更新)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 3:`playlogId` / `loginDateTime` 必须**逐字节等于** `UserLoginApi` 的返回值
|
||||||
|
|
||||||
|
**现象**:`playlogId` 自己造一个、或用了上一局的 `loginId` → 服务端静默拒收。
|
||||||
|
|
||||||
|
**根因**(`PacketUpsertUserAll.cs:31`):
|
||||||
|
这两个值**不是本接口产生的**,而是登录会话的标识。服务端拿它们找会话,
|
||||||
|
找不到就不应用。
|
||||||
|
|
||||||
|
**正确做法**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
login = UserLogin(user_id, token, ...) # 先登录
|
||||||
|
UpsertUserAll(user_id, login.loginId, # ★ 必须是它
|
||||||
|
login_date_time=login.loginDateTime, # ★ 必须是它
|
||||||
|
auth_date_time=login.dateTime, # userData.dateTime 用这个
|
||||||
|
...)
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ 容易混的三个时间**:
|
||||||
|
|
||||||
|
| 变量 | 是什么 | 用在哪 |
|
||||||
|
|---|---|---|
|
||||||
|
| `login.loginDateTime` | 「本地墙上时间当 UTC」(= 标准 Unix 秒 + 28800) | 顶层 `loginDateTime` 字段 |
|
||||||
|
| `login.dateTime` | 登录**请求**里发的 `Auth.AuthTime` | `userData.dateTime` |
|
||||||
|
| `loginDate`(playlog 里) | **标准 Unix 秒** | `userPlaylogList[].loginDate` |
|
||||||
|
|
||||||
|
**中途重新登录必须重取这两个值** —— 换了会话还发旧 `loginId` 一定失败。
|
||||||
|
|
||||||
|
**自检**:`validate_before_send()` 会检查 `playlogId` / `loginDateTime` 是否为 0。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 4:`confirm` 参数漏传 —— 闸门根本没开,但你以为开了
|
||||||
|
|
||||||
|
**现象**:`upsert.py --send` 跑完显示「上传成功」,其实**一个包都没发**。
|
||||||
|
|
||||||
|
**根因**:`UpsertUserAll` 的闸门规则是
|
||||||
|
`confirm=None` 时取 `config.UPSERT_CONFIRM`(默认 `False`)。
|
||||||
|
所以只写 `dry_run=False` **不够**,必须同时显式给 `confirm=True`。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✗ 错的:dry_run=False 但 confirm 没给 → 走 config 的 False → 被拦
|
||||||
|
UpsertUserAll(..., dry_run=False)
|
||||||
|
|
||||||
|
# ✓ 对的
|
||||||
|
UpsertUserAll(..., dry_run=False, confirm=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**这是「排查了半天以为服务端不收,其实是自己没发」的经典案例。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、结构坑:报文对不上
|
||||||
|
|
||||||
|
### 坑 5:`userWeeklyData` / `user2pPlaylog` 是**对象**,不是数组
|
||||||
|
|
||||||
|
36 个节点里有 **2 个** JSON 形态是 `{}` 而不是 `[]`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"userWeeklyData": { "lastLoginWeek": "...", "beforeLoginWeek": "...", "friendBonusFlag": true }
|
||||||
|
"user2pPlaylog" : { "userId1": 0, ..., "user2pPlaylogDetailList": [] }
|
||||||
|
```
|
||||||
|
|
||||||
|
传成数组会被 `coerce_node_value()` 直接抛 `PatchError`(刻意的防呆)。
|
||||||
|
|
||||||
|
**四种形态总览**:
|
||||||
|
|
||||||
|
| 类别 | 数量 | JSON 形态 |
|
||||||
|
|---|---:|---|
|
||||||
|
| 单元素数组(userData/userExtend/userOption/userRatingList) | 4 | `[{...}]` |
|
||||||
|
| **对象** | 2 | `{...}` ★ |
|
||||||
|
| 普通数组 | 19 | `[...]` |
|
||||||
|
| **位串** | 11 | `"0101..."` |
|
||||||
|
|
||||||
|
4 + 2 + 19 + 11 = 36。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 6:11 条位串的长度**必须**等于对应数组的条数
|
||||||
|
|
||||||
|
`isNew*` 与同名数组一一对应。长度不符 → 服务端误判「哪些是新增」。
|
||||||
|
|
||||||
|
`build_user_all()` 末尾用 `sync_bits_to_arrays()` 自动补齐,但如果你手工 `patch` 了数组,
|
||||||
|
位串也要跟着对。
|
||||||
|
|
||||||
|
**收藏夹是特例**(见坑 12):位串取值不是 `0/1` 而是 `1/2`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 7:`userExtend` 在 Upsert 侧**多出 2 个字段**
|
||||||
|
|
||||||
|
| | Get 侧 | Upsert 侧 |
|
||||||
|
|---|---:|---:|
|
||||||
|
| 键数 | 14 | **16** |
|
||||||
|
| 多出 | — | `isPhotoAgree`、`isGotoCodeRead` |
|
||||||
|
|
||||||
|
这两个字段**只在 Upsert 侧出现**(Get 响应里没有),必须手工补上,
|
||||||
|
否则报文比真机少 2 个键。
|
||||||
|
|
||||||
|
对应实现:`USER_EXTEND_EXTRA_KEYS`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 8:`userRating.udemae` 有一批**首字母大写的重复字段**,必须裁掉
|
||||||
|
|
||||||
|
**现象**:`GetUserRatingApi` 的响应里 `npcWinNum` 和 `NpcWinNum` **同时存在**(23 键)。
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// Get 侧(source/2026-10-05.log:87)—— 23 键
|
||||||
|
{ "npcWinNum": 0, ..., "NpcWinNum": 0, "NpcLoseNum": 0, "MaxLoseNum": 0, ... }
|
||||||
|
|
||||||
|
// Upsert 侧(source/2026-10-05.log:123)—— 只有小写 16 键
|
||||||
|
{ "npcWinNum": 0, ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**根因**:源码 `Net.VO.Mai2/UserUdemae.cs` 只声明了小写的 16 个字段,
|
||||||
|
大写那批是**服务端的冗余输出**。
|
||||||
|
|
||||||
|
**影响**:不裁掉的话 ① 报文里出现源码不存在的字段 ② 请求体比真机大 **119 字节**。
|
||||||
|
|
||||||
|
对应实现:`USER_RATING_UDEMAE_DROP_KEYS`。
|
||||||
|
|
||||||
|
> ★ 这个坑**只有做深度递归值对比才能发现** —— 光看键数或报文长度是看不出来的。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 9:`userCharacterList` 要按 `characterId` **升序**,不是 `charaSlot` 顺序
|
||||||
|
|
||||||
|
| | 顺序 |
|
||||||
|
|---|---|
|
||||||
|
| `userData.charaSlot` | `[395, 101, 400402, 400401, 200401]` |
|
||||||
|
| 真机 `userCharacterList` | `[101, 395, 200401, 400401, 400402]` |
|
||||||
|
|
||||||
|
**两者顺序不同** —— 回传时按 `characterId` 升序排。
|
||||||
|
|
||||||
|
对应实现:`grown.sort(key=lambda c: int(c.get("characterId", 0)))`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 10:字段裁剪 —— Get 侧有的,Upsert 侧不一定有
|
||||||
|
|
||||||
|
| 节点 | 要丢掉的字段 |
|
||||||
|
|---|---|
|
||||||
|
| `userData` | `friendCode`、`nameplateId`、`trophyId`、`cmLastEmoneyCredit`、`cmLastEmoneyBrand` |
|
||||||
|
| `userOption` | `tempoVolume` |
|
||||||
|
| `userCharacterList` | 只保留 `characterId`/`level`/`awakening`/`useCount` |
|
||||||
|
| `userMusicDetailList` | `extNum2` |
|
||||||
|
| `userChargeList` | `extNum1` |
|
||||||
|
| `userMissionDataList` / `userWeeklyData` | `userId` |
|
||||||
|
|
||||||
|
### 坑 10.1:`banState` 的位置会「漂移」
|
||||||
|
|
||||||
|
- **Get 侧**:`banState` 是 `userData` 的**同级顶层**字段
|
||||||
|
- **Upsert 侧**:它被**移进了 `userData` 内部**
|
||||||
|
|
||||||
|
`_extract_from_get_responses()` 会自动搬运,但如果你自己手工拼快照,别忘了。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、字段值坑:结构对了但值错了
|
||||||
|
|
||||||
|
### 坑 11:`characterLevelN` 是**游玩前**等级,不要加经验
|
||||||
|
|
||||||
|
**源码**(`VOExtensions.cs:1361`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
result.characterLevel1 = (int)gameScore.CharaLevel[0];
|
||||||
|
```
|
||||||
|
|
||||||
|
`gameScore.CharaLevel` 是**本次开始游玩时**灌进去的快照,整局不变。
|
||||||
|
|
||||||
|
**三个真机会话的三方对照**(这是唯一能定论的方式):
|
||||||
|
|
||||||
|
| 会话 | Get 侧 `userCharacterList` | `userPlaylogList[].characterLevel` | Upsert 侧 `userCharacterList` |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 00:20 | 3800/4455/134 | **3800/4455/134** | 3801/4456/135 |
|
||||||
|
| 19:21 | 3801/4456/135 | **3801/4456/135** | 3802/4457/136 |
|
||||||
|
| 22:31 | 3802/4457/136 | **3802/4457/136** | 3803/4458/137 |
|
||||||
|
|
||||||
|
**结论:`playlog.characterLevel` 恒等于 Get 侧(游玩前),
|
||||||
|
`upsertUserAll.userCharacterList` 恒为 +1(游玩后)。**
|
||||||
|
|
||||||
|
> ⚠ 我一度写成 `level + character_exp_gain`,因为只看了单个会话、
|
||||||
|
> 把「Get 侧」和「Upsert 侧」的数值记混了。**横向比三个会话立刻暴露。**
|
||||||
|
|
||||||
|
**推荐做法**:别手填 `config.PLAY_CHARACTERS`,直接用
|
||||||
|
`derive_play_characters(snapshot)` 从服务器快照推(`upsert.py` 已接上)。
|
||||||
|
账号一升级手填值就过期,会出现「playlog 与 userCharacterList 对不上」的诡异现象。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 12:收藏夹**不做剔除**,且位串取值是 `1/2` 不是 `0/1`
|
||||||
|
|
||||||
|
`build_favorite_delta()` 复刻 `VOExtensions.cs:345-367`:
|
||||||
|
|
||||||
|
- **恒返回完整 5 条**(不做差分剔除)
|
||||||
|
- 位串按真机 `"22212"` 反推:`"2"` = 服务器侧有该 itemKind,`"1"` = 查不到
|
||||||
|
|
||||||
|
**★ 必须用未归一化的原始 Get 响应作基线**:
|
||||||
|
真机里 `itemKind=10`(搭档)的 Get 响应回的是 `{"itemKind": null}`,
|
||||||
|
正因为查不到才产生那个 `"1"`。
|
||||||
|
但 `user_favorite.py` 会「补 itemKind」,补完之后就看不出原始 null 了。
|
||||||
|
|
||||||
|
**两个真机会话都是 `"22212"`** —— 第 4 位(itemKind=10)**稳定为 "1"**,
|
||||||
|
说明服务端对该 kind 恒回 null。
|
||||||
|
|
||||||
|
**实现上的处理**:
|
||||||
|
- 有原始响应 → 用它建索引,自然得到 `"22212"`
|
||||||
|
- 只有归一化基线 → 传 `assume_missing=FAVORITE_MISSING_KINDS`(= `(10,)`)兜底
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 13:`loginDate` 是**标准 Unix 秒**,不是 `loginDateTime`
|
||||||
|
|
||||||
|
```
|
||||||
|
loginDateTime = 1791239482 (「墙上时间当 UTC」= 标准 Unix 秒 + 28800)
|
||||||
|
loginDate = 1791210683 (标准 Unix 秒)
|
||||||
|
```
|
||||||
|
|
||||||
|
**三个真机会话的实际差值**:
|
||||||
|
|
||||||
|
| 会话 | `loginDateTime` | `loginDate` | 差 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 00:20 | 1791159576 | 1791130777 | **28799** |
|
||||||
|
| 19:21 | 1791228113 | 1791199315 | **28798** |
|
||||||
|
| 22:31 | 1791239482 | 1791210683 | **28799** |
|
||||||
|
|
||||||
|
**注意不是整 28800!** 原因:`loginDate` 来自
|
||||||
|
`TimeManager.GetUnixTime(src.Detail.LastLoginDate)`(`VOExtensions.cs:1290`),
|
||||||
|
而 `lastLoginDate` 是客户端在**登录响应处理完那一刻**写的,比 `loginDateTime` 晚 1~2 秒。
|
||||||
|
|
||||||
|
本工程用 `loginDateTime − 28800`,**稳定差 1~2 秒**。
|
||||||
|
服务端两次都接受了 → 它**不对这几个时刻做等式校验**。知道这一点可以省掉一轮无谓排查。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 14:`playDate` 是**日志日**(7 点分界),不是自然日
|
||||||
|
|
||||||
|
源码 `TimeManager.cs:77-85`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static DateTime ToLogDateTime(DateTime dateTime, int hour = 0)
|
||||||
|
{
|
||||||
|
DateTime result = new(dateTime.Year, dateTime.Month, dateTime.Day, hour, 0, 0);
|
||||||
|
if (7 > dateTime.Hour) return result.AddDays(-1.0); // ★ 7 点前回退一天
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
真机验证:00:20 那一局的 `userPlaylogList[0].playDate = "2026-10-04"`
|
||||||
|
(自然日是 10-05,因为 00:20 < 07:00 所以回退)。19:21 那局则是 `"2026-10-05"`。
|
||||||
|
|
||||||
|
**`playDate` 是日期串(`YYYY-MM-DD`),`userPlayDate` 才是完整时间串**,别搞混。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 15:`lastLoginDate` 与 `lastPlayDate` 是**两个不同的时刻**
|
||||||
|
|
||||||
|
**现象**:所有时间字段都填成 `loginDateTime` → 数值上「能用」,但和真机对不上。
|
||||||
|
|
||||||
|
**真机规律**(三个会话全部成立):
|
||||||
|
|
||||||
|
| 字段 | 语义 | 00:20 会话 | 19:21 会话 | 22:31 会话 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `userData.lastLoginDate` | **登录**时刻 | 00:19:37.0 | 19:21:55.0 | 22:31:23.0 |
|
||||||
|
| `userData.eventWatchedDate` | **与 lastLoginDate 同值** | 00:19:37.0 | 19:21:55.0 | 22:31:23.0 |
|
||||||
|
| `userData.lastPlayDate` | **上报**时刻 | 00:20:57.0 | 19:23:02.0 | 22:32:21.0 |
|
||||||
|
| `userGamePlaylogList[0].playDate` | **与 lastPlayDate 同值** | 00:20:57.0 | 19:23:02.0 | 22:32:21.0 |
|
||||||
|
| `userPlaylogList[0].userPlayDate` | **游玩**时刻(更早) | 00:20:30.0 | 19:22:36.0 | 22:31:59.0 |
|
||||||
|
|
||||||
|
即:登录 → 游玩 → 上报是三个时刻,真机分别是
|
||||||
|
「loginDateTime +1~2s」「userPlayDate」「上报那一刻」。
|
||||||
|
|
||||||
|
本工程把「游玩/上报时刻」抽成参数 `play_date_time`(不传则退回 `login_date_time`),
|
||||||
|
`upsert.py` 传 `login_date_time_now()`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 16:`userGetPointList` / `userLoginBonusList` 是**登录奖励**才产生的
|
||||||
|
|
||||||
|
**真机证据**:
|
||||||
|
|
||||||
|
| 会话 | `userData.point` | `userGetPointList` | `userLoginBonusList` |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 00:20 | 4110 | `[]` | `[]` |
|
||||||
|
| 19:21 | 4460 | `[{"getKind":1,"point":200},{"getKind":2,"point":150}]` | `[{"bonusId":61,"point":1,"isCurrent":true,...}]` |
|
||||||
|
| 22:31 | 4460 | `[]` | `[]` |
|
||||||
|
|
||||||
|
**规律**:这两个节点非空 ⟺ 本次登录触发了奖励发放。
|
||||||
|
留空则 `point` 保持基线值不变 —— 这是「不发放点数」的**必然结果,不是 bug**。
|
||||||
|
|
||||||
|
**★ 排查陷阱**:如果你把 `point` 手工 +200 但 `userGetPointList` 留空,
|
||||||
|
会出现「数字对不上」的矛盾。要么两个一起改,要么两个都不动。
|
||||||
|
|
||||||
|
对应实现:`DEFAULT_EMPTY_NODES`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 17:`userGamePlaylogList[].playCount` 是**机台本地计数器**,服务器不返回
|
||||||
|
|
||||||
|
真机值:00:20 会话 = **8**、19:21 = **9**、22:31 = **10**(每打一局 +1)。
|
||||||
|
|
||||||
|
源码 `VOExtensions.cs:881`:`result.playCount = Backup.localParameter.playCount`。
|
||||||
|
|
||||||
|
**任何 Get 接口都不返回它** —— 既推不出来,服务端也不会纠正。
|
||||||
|
填错只是统计数字不对,不影响落库。放在 `config.PLAY_LOCAL_PLAY_COUNT`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 18:`playSpecial` 不是 0,是一枚**随机位掩码**
|
||||||
|
|
||||||
|
源码 `VOExtensions.cs:846-855`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
int max = 1037933;
|
||||||
|
int num2 = UnityEngine.Random.Range(1, max) * 2069;
|
||||||
|
num2 += GameManager.CalcSpecialNum();
|
||||||
|
int num3 = 0;
|
||||||
|
for (int i = 0; i < 32; i++) { num3 <<= 1; num3 += num2 % 2; num2 >>= 1; }
|
||||||
|
result.playSpecial = num3;
|
||||||
|
```
|
||||||
|
|
||||||
|
**`CalcSpecialNum()` 是机台硬件标志位掩码**(`GameManager.cs:332-378`),
|
||||||
|
逐位含义:`SpecialAP`、`IsAutoPlay()`、键鼠数量、`IsDevelop`、
|
||||||
|
以及三条**否定条件**(不含 "ACA" / 不含 "GeForce GTX" / 不含 "Core(TM) i3-8100"),
|
||||||
|
最后 `num |= 0x400` **无条件置位**。
|
||||||
|
|
||||||
|
**真机实测**:`[CalcSpecialNum] = 1024`(= 0x400,标准 ACA 机台其余位恰好全 0)。
|
||||||
|
|
||||||
|
**反推验证**(这一步很有说服力):
|
||||||
|
|
||||||
|
```
|
||||||
|
真机 playSpecial = 892014272
|
||||||
|
位反转32(892014272) = 57726124 = 27900 × 2069 + 1024
|
||||||
|
└───┬───┘ └─┬─┘
|
||||||
|
随机系数 余数 = CalcSpecialNum ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
配置项:`config.PLAY_SPECIAL_NUM = 1024`。
|
||||||
|
|
||||||
|
> 注意位反转后可能超过 int32 变成负数 —— 真机 19:21 那局就是 `-603697438`,这是正常的。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 19:`extBool1/2/3` 是 bool 但**名字不带 `is` 前缀**
|
||||||
|
|
||||||
|
用 `name.startswith("is")` 判断布尔字段会**漏掉这三个**,
|
||||||
|
导致序列化成 `0` 而不是 `false`。
|
||||||
|
|
||||||
|
同理字符串字段(`vsUserName`、`playedUserName1..3`、`placeName`、`playDate`、`userPlayDate`)
|
||||||
|
默认值必须是 `""` 而不是 `0`。
|
||||||
|
|
||||||
|
对应实现:显式集合 `PLAYLOG_BOOL_FIELDS` / `PLAYLOG_STRING_FIELDS`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 20:110 个字段**一个都不能省**
|
||||||
|
|
||||||
|
把真机报文的 `userPlaylogList[0]` 与 `Net.VO.Mai2/UserPlaylog.cs` 做双向集合比对:
|
||||||
|
|
||||||
|
| 方向 | 差异 |
|
||||||
|
|---|---|
|
||||||
|
| C# 声明但报文未出现 | **0** |
|
||||||
|
| 报文有但 C# 未声明 | **0** |
|
||||||
|
|
||||||
|
**能精简的是「填写的内容要真实」,而不是删字段。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、网络层坑
|
||||||
|
|
||||||
|
### 坑 21:`number` 头只由 **Ping 心跳**刷新,不是每个请求都更新
|
||||||
|
|
||||||
|
**真机 70 个请求里它只变过一次**:
|
||||||
|
|
||||||
|
```
|
||||||
|
#1 ~ #10 number = 0 ← 还没跑过心跳
|
||||||
|
#11 ~ #17 Ping 包(按 Packet.cs:187 **不带** number 头)
|
||||||
|
#18 ~ #70 number = 63 ← 心跳往返 63ms,此后 53 个业务请求恒定
|
||||||
|
```
|
||||||
|
|
||||||
|
**源码链路**:
|
||||||
|
|
||||||
|
```
|
||||||
|
PingChecker.cs:166 StartPacket(new PacketPing(PingSuccess, PingError))
|
||||||
|
PacketPing.cs:34 _onDone((int)sw.ElapsedMilliseconds) ← 值 = Ping 包往返毫秒
|
||||||
|
PingChecker.cs:227 GameManager.PingMsec = pingMsec
|
||||||
|
Packet.cs:189 Client.AddHeader("number", GameManager.PingMsec.ToString())
|
||||||
|
```
|
||||||
|
|
||||||
|
**我原来的错法**:每个请求结束后把「本请求耗时」写进 `ping_msec`,
|
||||||
|
于是 number 随每个包抖动。已改成只有 Ping 包更新。
|
||||||
|
|
||||||
|
**调用方注意**:`upsert.py` 在取完服务器设置后加了一步 `Ping()`,否则 number 恒为 0。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 22:Cookie —— 登录响应**会**下发 `JSESSIONID`,而且必须带着
|
||||||
|
|
||||||
|
**这个坑我踩了两次(先误判、后自我推翻)**:
|
||||||
|
|
||||||
|
1. hook 日志里显示 `Cookie : userId=12345678` —— 这是我 hook **自己拼的假串**
|
||||||
|
(`gamehook/Main.cs:196-199` 的 `_cookie = "userId=" + userId`),不是真实 Cookie 头。
|
||||||
|
2. 于是我用 `Ping` 探针去验证,看到响应头里**没有 Set-Cookie**,
|
||||||
|
就得出「服务器从不下发 Cookie,无需处理」的结论 —— **这是错的**。
|
||||||
|
3. 后来跑完整登录链路,`UserLoginApi` 的响应头里明确有:
|
||||||
|
```
|
||||||
|
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
|
||||||
|
```
|
||||||
|
|
||||||
|
**源码**(`Packet.cs:228-234`):**只在 `UserLogin` 响应上**把 Set-Cookie 收进
|
||||||
|
`CookieContainer`;之后 `Packet.cs:190-193` 每个带 userId 的请求都挂回去。
|
||||||
|
|
||||||
|
**结论**:这是**真实的会话粘滞**。
|
||||||
|
`requests.Session` 会自动处理,**但前提是全程复用同一个 `MaimaiNetClient`**
|
||||||
|
(本工程各 api 函数在 `client=None` 时都走 `get_default_client()` 单例)。
|
||||||
|
**如果谁手动 new 一个 client 去发结算包,就会丢掉 JSESSIONID。**
|
||||||
|
|
||||||
|
> **教训**:用「只读探针」验证一件事时,要确认探针覆盖了完整链路 ——
|
||||||
|
> 我只探了 Ping,就下了关于整个服务器的结论。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 23:`Accept-Encoding` 必须**显式置空**
|
||||||
|
|
||||||
|
`requests` 默认发 `Accept-Encoding: gzip, deflate`。
|
||||||
|
本协议响应体是 AES 密文(二进制),服务端若据此做内容编码会返回**空 body**。
|
||||||
|
|
||||||
|
实测:不发该头 → HTTP 200 / 0 字节;显式置空 → 正常返回。
|
||||||
|
|
||||||
|
C# 端不需要是因为 `HttpWebRequest` 默认 `AutomaticDecompression = None`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 24:扫码 chipID 用**完整形式**,游戏服务器用**短值**
|
||||||
|
|
||||||
|
| 用途 | 正确值 | 源码依据 |
|
||||||
|
|---|---|---|
|
||||||
|
| aimedb 扫码 | `XXXX-XXXXXXXXXXX`(**带连字符**) | `ChimeReaderManager.cs:250` `KeychipId.Value` |
|
||||||
|
| 游戏服务器 `clientId` | `TESTCLIENT1`(短值) | `PacketGetGameSetting.cs:23` `KeychipId.ShortValue` |
|
||||||
|
|
||||||
|
我原来扫码也用了短值,导致 aimedb 返回错误。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、工程/流程坑
|
||||||
|
|
||||||
|
### 坑 25:不做**落库回查**,你永远不知道有没有写进去
|
||||||
|
|
||||||
|
`returnCode=1` 完全不可信(见坑 1~4)。**必须**发包后重新拉一次数据比对。
|
||||||
|
|
||||||
|
`upsert.py --send` 的第 7.5 步已经内置:
|
||||||
|
|
||||||
|
```
|
||||||
|
7.5 落库回查(重新拉服务器数据)
|
||||||
|
playCount 1260 -> 1261
|
||||||
|
currentPlayCount 188 -> 189
|
||||||
|
lastPlayDate 2026-10-05 22:32:21 -> 2026-10-06 01:28:04
|
||||||
|
musicId=143 level=4 playCount -> 11
|
||||||
|
【落库成功】userData.playCount 已 +1
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么在登出前回查**:同一个登录会话内 `GetUserDataApi` 立刻能读到新值,
|
||||||
|
不用再开一次会话(也就不会消耗第二个二维码)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 26:报文体长度差异要**逐节点**看,不要只看总长
|
||||||
|
|
||||||
|
总长差几字节可能来自随机数位数(`playSpecial`),也可能来自真实的结构错误。
|
||||||
|
**逐节点比对字节数**才能定位:
|
||||||
|
|
||||||
|
```
|
||||||
|
节点 真机068 本次 差
|
||||||
|
userData 1985 1985 0
|
||||||
|
...
|
||||||
|
userGamePlaylogList 294 295 1 <<< 只有它差(playSpecial 随机数)
|
||||||
|
(36 节点合计) 14255 14256 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**35 个节点 0 差异**才是「结构完全对齐」的证据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 坑 27:`--debug` 日志会很大
|
||||||
|
|
||||||
|
DEBUG 模式会完整打印每个请求的头 + 体 + 响应体,
|
||||||
|
一次结算流程(含 19 节点快照 + 739 组曲目分页)能到 **18 MB**。
|
||||||
|
|
||||||
|
只想看流程就别加 `--debug`;想减小体积调 `config.LOG_VALUE_MAX`(0 = 不截断)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、发包前 Checklist
|
||||||
|
|
||||||
|
按顺序过一遍,能挡掉上面绝大多数坑:
|
||||||
|
|
||||||
|
- [ ] **闸门**:`confirm=True`(或 `config.UPSERT_CONFIRM=True`)
|
||||||
|
- [ ] **会话**:`playlogId == login.loginId`,`loginDateTime == login.loginDateTime`
|
||||||
|
- [ ] **同会话**:中途没重新登录过(重登必须重取上面两个值)
|
||||||
|
- [ ] **快照**:`fetch_upsert_snapshot()` 抓了 19 个节点,失败 0 个
|
||||||
|
- [ ] **userData 演算**:`playCount`/`currentPlayCount` +1,`lastPlayDate` 是上报时刻,
|
||||||
|
`lastLoginDate`/`eventWatchedDate` 是登录时刻,`dateTime` 非 0
|
||||||
|
- [ ] **差分节点**:`userMusicDetailList` 至少 1 条(本局曲目战绩);
|
||||||
|
`userMapList` / `userCharacterList` 按 config 增量决定 ——
|
||||||
|
`PLAY_MAP_DISTANCE_GAIN` / `PLAY_CHARACTER_EXP_GAIN` **默认都是 0**,
|
||||||
|
此时它们为空是**预期行为**;只有把增量设成非 0 时才必须有条目
|
||||||
|
- [ ] **`userChargeList`** 是整表回传,没被当差分收敛成空
|
||||||
|
- [ ] **位串**:11 条长度等于对应数组条数;`isNewFavoriteList` 应为 `"22212"`
|
||||||
|
- [ ] **playlog**:110 个字段齐全;`characterLevelN` 是**游玩前**等级
|
||||||
|
- [ ] **playSpecial**:非 0,且余数 = `config.PLAY_SPECIAL_NUM`
|
||||||
|
- [ ] **网络**:跑过 `Ping()`(number 头),同一个 client(Cookie)
|
||||||
|
- [ ] **自检**:`validate_before_send()` 返回 0 个问题
|
||||||
|
- [ ] **回查**:发包后确认 `playCount` 真的 +1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六点五、关于 `userMapList` / `userCharacterList` 的默认空值
|
||||||
|
|
||||||
|
本工程默认:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PLAY_MAP_DISTANCE_GAIN = 0 # 本局不推进地图 -> userMapList 回传 []
|
||||||
|
PLAY_CHARACTER_EXP_GAIN = 0 # 本局不给角色加经验 -> userCharacterList 回传 []
|
||||||
|
```
|
||||||
|
|
||||||
|
**这不是坑,是刻意的默认值** —— 因为「地图走多远」「角色升几级」取决于真实游玩,
|
||||||
|
纯网络脚本推不出来。想要真机那种 +11000 / +1 级,把这两个配置改回去即可。
|
||||||
|
|
||||||
|
★ 唯一要注意的连带影响:`validate_before_send()` 的「差分节点为空」告警
|
||||||
|
只在**增量非 0 却没产出条目**时才触发,所以默认值下不会刷无意义的告警。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、这个清单是怎么攒出来的
|
||||||
|
|
||||||
|
| 阶段 | 手段 | 抓到的坑 |
|
||||||
|
|---|---|---|
|
||||||
|
| 初版实现 | 读源码 + 单个真机日志 | 坑 5、6、13、14、18、19、20、23、24 |
|
||||||
|
| 深度值对比 | 逐字段递归 diff 真机报文 vs 本工程 | 坑 7、8、9、10 |
|
||||||
|
| 落库失败排查 | 直接 `patch` 绝对值探测服务端行为 | 坑 1、2、3、4 |
|
||||||
|
| **MelonLoader hook 抓真机** | 在游戏里 hook 网络层抓完整会话 | 坑 11、12、15、16、17、21、22 |
|
||||||
|
| 真实跑通 | `--send` + 落库回查 | 坑 25、26、27 |
|
||||||
|
|
||||||
|
**最有效的一招是「横向比多个真机会话」** —— 坑 11(characterLevel 语义)、
|
||||||
|
坑 13(loginDate 差 28798/28799)、坑 15(两个时刻分离)都是单看一个会话看不出来、
|
||||||
|
一横向比就立刻暴露的。
|
||||||
|
|
||||||
|
第二有效的是 **MelonLoader hook** —— 它能看到 HTTP 头(`number`)、
|
||||||
|
能抓到 `CalcSpecialNum()` 的真实返回值,这些在纯日志里根本看不到。
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# netdelivery.py 说明(ALL.Net DLI)
|
||||||
|
|
||||||
|
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里
|
||||||
|
> netdelivery 部分的**详细版**(含运行原理)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、DLI 是什么
|
||||||
|
|
||||||
|
**DLI = Download Instruction(配信指示書)。**
|
||||||
|
|
||||||
|
机台做完 ALL.Net 认证后,AMDaemon 会再问一次「我该下什么包」:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST http://at.sys-allnet.cn/net/delivery/instruction
|
||||||
|
body = title_id=SDGB&title_ver=<版本>&client_id=<Keychip 短值>
|
||||||
|
|
||||||
|
→ result=1&uri=<patch 指示書 URL>|<option 指示書 URL>
|
||||||
|
```
|
||||||
|
|
||||||
|
指示書本身是个 INI(日文注释),真正要下的包写在 `INSTALLn=` 里:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[COMMON] INSTALL1=https://.../SDGB_1.56.00_..._1_1.55.01.app
|
||||||
|
[OPTIONAL] INSTALL1=https://.../SDGB_A031_..._0.opt
|
||||||
|
INSTALL2=https://.../SDGB_A005_..._0.opt
|
||||||
|
```
|
||||||
|
|
||||||
|
> ★ 只有 `INSTALLn=` 才是**要下载的包**;
|
||||||
|
> `EXISTn=` 是「本机应该已经有的文件」,只是文件名,不是下载项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、协议出处
|
||||||
|
|
||||||
|
全部来自 `source/amdaemon.exe` 的字符串(数字是文件内偏移):
|
||||||
|
|
||||||
|
| 字符串 | 偏移 |
|
||||||
|
|---|---|
|
||||||
|
| `http://at.sys-all.cn/net/initialize` | `@0x788170` |
|
||||||
|
| `http://at.sys-all.cn/net/delivery/instruction` | `@0x788198` |
|
||||||
|
| `title_id=%s&title_ver=%s&client_id=%s` | `@0x7882b8` |
|
||||||
|
| `ALWE_NETDELIVER_PHASE_GET_DLINST_INFO` | `@0x63bde0` |
|
||||||
|
| `ALWE_NETDELIVER_PHASE_APP_IMAGE_INFO` | `@0x63be08` |
|
||||||
|
| `ALWE_NETDELIVER_PHASE_OPT_IMAGE_INFO` | `@0x63be30` |
|
||||||
|
| `"Net deliver param - dli uri : %s."` | `@0x63c3f8` |
|
||||||
|
| `ALE_ABEX_STAT_DLINSTR_OK` / `_ERR_DLINSTR_NG` | `@0x638600` |
|
||||||
|
| `alAbEx_lite Ver 4.00.01` | `@0x784a31` |
|
||||||
|
|
||||||
|
协议是 ALL.Net **alAbEx v4**:AES-128-CBC 信封,明文是 `key=value&...`。
|
||||||
|
|
||||||
|
```
|
||||||
|
请求 = AES-CBC( 16 字节全 0 前缀 || 明文, IV = 全 0 )
|
||||||
|
响应 = 16 字节 IV || 密文
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python netdelivery.py # 用 config.py 里的机台查,然后**多选下载**
|
||||||
|
python netdelivery.py A63E01ABCDE # 指定 Keychip
|
||||||
|
python netdelivery.py --list # 只列清单 + 直链,不下载
|
||||||
|
python netdelivery.py --all # 全下(不交互)
|
||||||
|
python netdelivery.py --only 1,3 # 只下第 1、3 项(不交互)
|
||||||
|
python netdelivery.py --ver 1.55 # 指定上报版本(默认 config.MAI_ENCODING)
|
||||||
|
python netdelivery.py --out D:\SDGB2026\update
|
||||||
|
python netdelivery.py --insecure # 关掉 TLS 校验(CDN 证书有问题时用)
|
||||||
|
```
|
||||||
|
|
||||||
|
交互选择支持:逗号/空格分隔、区间(`1-3`)、`a`=全选、`n`=全不选、`q`=退出。
|
||||||
|
|
||||||
|
下载带**断点续传**(`.part` 临时文件 + `Range`,下完改名)与进度/速率显示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、实测结论(2026-10-06)
|
||||||
|
|
||||||
|
| 结论 | 证据 |
|
||||||
|
|---|---|
|
||||||
|
| `title_ver` 决定拿哪份指示書 | `1.55` → patch 1.56 + opt A005;`1.56` → 仅 opt A031;`≤1.54` → `uri=null` |
|
||||||
|
| `client_id` **必须**是 11 位短值 | 发 15 位完整 Keychip → `result=-3`;发小写 → `result=-2` |
|
||||||
|
| 包可以断点续传 | 返回 `206` + `Content-Range` |
|
||||||
|
| 已下架的历史包会回 **504** | 这是华立 CDN 的状态,**不是脚本的问题** |
|
||||||
|
|
||||||
|
实测清单(`--ver 1.56`):
|
||||||
|
|
||||||
|
```
|
||||||
|
序号 类型 大小 来源段/键 文件名
|
||||||
|
[ 1] opt 7.25 MB COMMON/INSTALL1 SDGB_A031_20260909101347_0.opt
|
||||||
|
[ 2] opt 3.00 MB OPTIONAL/INSTALL1 SDGB_A005_20260610180958_0.opt
|
||||||
|
```
|
||||||
|
|
||||||
|
下载并校验字节数完全一致:`SDGB_A031...opt` = 7602176、`SDGB_A005...opt` = 3145728。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、注意
|
||||||
|
|
||||||
|
- 默认下载到 `download/`(已加入 `.gitignore`,`.app` 动辄上 GB)。
|
||||||
|
- 需要同目录下的 `authv1.py` 提供 ALL.Net 协议层。
|
||||||
|
- 这是**只读**操作,不写任何账号数据。
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"user_id": 12345678,
|
||||||
|
"login": {
|
||||||
|
"loginId": 7693120247791879616,
|
||||||
|
"loginDateTime": 1791222938,
|
||||||
|
"lastLoginDate": "2026-10-05 17:53:49",
|
||||||
|
"loginCount": 184,
|
||||||
|
"consecutiveLoginCount": 3,
|
||||||
|
"token": "HcXOwSdDwBSEPzngjdfp"
|
||||||
|
},
|
||||||
|
"auth_date_time": 1791194138,
|
||||||
|
"snapshot_summary": {
|
||||||
|
"userData": 1,
|
||||||
|
"userExtend": 1,
|
||||||
|
"userOption": 1,
|
||||||
|
"userCharacterList": 312,
|
||||||
|
"userMapList": 104,
|
||||||
|
"userLoginBonusList": 38,
|
||||||
|
"userRatingList": 1,
|
||||||
|
"userItemList": 1070,
|
||||||
|
"userMusicDetailList": 1037,
|
||||||
|
"userCourseList": 16,
|
||||||
|
"userChargeList": 3,
|
||||||
|
"userFavoriteList": 5,
|
||||||
|
"userActivityList": 1,
|
||||||
|
"userMissionDataList": 6,
|
||||||
|
"userIntimateList": 2,
|
||||||
|
"userFavoritemusicList": 27,
|
||||||
|
"userKaleidxScopeList": 7,
|
||||||
|
"userGhost": 15
|
||||||
|
},
|
||||||
|
"snapshot_failed": [],
|
||||||
|
"preview": {
|
||||||
|
"userName": "☆Astral☆",
|
||||||
|
"playerRating": 15000,
|
||||||
|
"isLogin": false,
|
||||||
|
"banState": 0,
|
||||||
|
"errorId": 0,
|
||||||
|
"lastLoginDate": "1970-01-01 00:00:00",
|
||||||
|
"lastPlayDate": "1970-01-01 00:00:00",
|
||||||
|
"totalAwake": 184,
|
||||||
|
"iconId": 559501,
|
||||||
|
"frameId": 0
|
||||||
|
},
|
||||||
|
"qr_run_at": "2026-10-05 17:53"
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+27889
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,626 @@
|
|||||||
|
## 顶层字段对比
|
||||||
|
|
||||||
|
| 字段 | 真机 | 本次实跑 | 一致 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `userId` | 12345678 | 12345678 | 一致 |
|
||||||
|
| `playlogId` | 7692848101484135861 | 7693120247791879616 | **不同** |
|
||||||
|
| `isEventMode` | False | False | 一致 |
|
||||||
|
| `isFreePlay` | False | False | 一致 |
|
||||||
|
| `loginDateTime` | 1791159576 | 1791222938 | **不同** |
|
||||||
|
| `userPlaylogList` | 1 条 | 1 条 | 一致 |
|
||||||
|
| `upsertUserAll` | 36 节点 | 36 节点 | 一致 |
|
||||||
|
|
||||||
|
## 36 个节点逐节点对比
|
||||||
|
|
||||||
|
| # | 节点 | 真机 | 实跑 | 形态一致 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `userData` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 2 | `userExtend` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 3 | `userOption` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 4 | `userCharacterList` | 5 条 | 5 条 | 一致 |
|
||||||
|
| 5 | `userGhost` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 6 | `userMapList` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 7 | `userLoginBonusList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 8 | `userRatingList` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 9 | `userItemList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 10 | `userMusicDetailList` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 11 | `userCourseList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 12 | `userFriendSeasonRankingList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 13 | `userChargeList` | 3 条 | 3 条 | 一致 |
|
||||||
|
| 14 | `userFavoriteList` | 5 条 | 5 条 | 一致 |
|
||||||
|
| 15 | `userActivityList` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 16 | `userMissionDataList` | 6 条 | 6 条 | 一致 |
|
||||||
|
| 17 | `userWeeklyData` | 对象(3 键) | 对象(3 键) | 一致 |
|
||||||
|
| 18 | `userGamePlaylogList` | 1 条 | 1 条 | 一致 |
|
||||||
|
| 19 | `user2pPlaylog` | 对象(7 键) | 对象(7 键) | 一致 |
|
||||||
|
| 20 | `userIntimateList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 21 | `userShopItemStockList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 22 | `userGetPointList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 23 | `userTradeItemList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 24 | `userFavoritemusicList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 25 | `userKaleidxScopeList` | 0 条 | 0 条 | 一致 |
|
||||||
|
| 26 | `isNewCharacterList` | 位串 '00000' | 位串 '00000' | 一致 |
|
||||||
|
| 27 | `isNewMapList` | 位串 '0' | 位串 '0' | 一致 |
|
||||||
|
| 28 | `isNewLoginBonusList` | 位串 '' | 位串 '' | 一致 |
|
||||||
|
| 29 | `isNewItemList` | 位串 '' | 位串 '' | 一致 |
|
||||||
|
| 30 | `isNewMusicDetailList` | 位串 '0' | 位串 '0' | 一致 |
|
||||||
|
| 31 | `isNewCourseList` | 位串 '' | 位串 '' | 一致 |
|
||||||
|
| 32 | `isNewFavoriteList` | 位串 '22212' | 位串 '22212' | 一致 |
|
||||||
|
| 33 | `isNewFriendSeasonRankingList` | 位串 '' | 位串 '' | 一致 |
|
||||||
|
| 34 | `isNewUserIntimateList` | 位串 '' | 位串 '' | 一致 |
|
||||||
|
| 35 | `isNewFavoritemusicList` | 位串 '' | 位串 '' | 一致 |
|
||||||
|
| 36 | `isNewKaleidxScopeList` | 位串 '' | 位串 '' | 一致 |
|
||||||
|
|
||||||
|
## 11 条位串对比
|
||||||
|
|
||||||
|
| 位串 | 真机 | 实跑 | 一致 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `isNewCharacterList` | `00000` | `00000` | 一致 |
|
||||||
|
| `isNewMapList` | `0` | `0` | 一致 |
|
||||||
|
| `isNewLoginBonusList` | `` | `` | 一致 |
|
||||||
|
| `isNewItemList` | `` | `` | 一致 |
|
||||||
|
| `isNewMusicDetailList` | `0` | `0` | 一致 |
|
||||||
|
| `isNewCourseList` | `` | `` | 一致 |
|
||||||
|
| `isNewFavoriteList` | `22212` | `22212` | 一致 |
|
||||||
|
| `isNewFriendSeasonRankingList` | `` | `` | 一致 |
|
||||||
|
| `isNewUserIntimateList` | `` | `` | 一致 |
|
||||||
|
| `isNewFavoritemusicList` | `` | `` | 一致 |
|
||||||
|
| `isNewKaleidxScopeList` | `` | `` | 一致 |
|
||||||
|
|
||||||
|
## userData[0] 逐字段对比
|
||||||
|
|
||||||
|
| 字段 | 真机 | 实跑 | 一致 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `accessCode` | "" | "" | 一致 |
|
||||||
|
| `userName` | "☆Astral☆" | "☆Astral☆" | 一致 |
|
||||||
|
| `isNetMember` | 1 | 1 | 一致 |
|
||||||
|
| `point` | 4110 | 4110 | 一致 |
|
||||||
|
| `totalPoint` | 52620 | 52620 | 一致 |
|
||||||
|
| `iconId` | 559501 | 559501 | 一致 |
|
||||||
|
| `plateId` | 458001 | 458001 | 一致 |
|
||||||
|
| `titleId` | 55502 | 55502 | 一致 |
|
||||||
|
| `partnerId` | 1 | 1 | 一致 |
|
||||||
|
| `frameId` | 209504 | 209504 | 一致 |
|
||||||
|
| `selectMapId` | 550003 | 550003 | 一致 |
|
||||||
|
| `totalAwake` | 184 | 184 | 一致 |
|
||||||
|
| `gradeRating` | 0 | 0 | 一致 |
|
||||||
|
| `musicRating` | 15000 | 15000 | 一致 |
|
||||||
|
| `playerRating` | 15000 | 15000 | 一致 |
|
||||||
|
| `highestRating` | 15000 | 15000 | 一致 |
|
||||||
|
| `gradeRank` | 0 | 0 | 一致 |
|
||||||
|
| `classRank` | 1 | 1 | 一致 |
|
||||||
|
| `courseRank` | 10 | 10 | 一致 |
|
||||||
|
| `charaSlot` | [395, 101, 400402, 400401, 200401] | [395, 101, 400402, 400401, 200401] | 一致 |
|
||||||
|
| `charaLockSlot` | [395, 101, 0, 0, 0] | [395, 101, 0, 0, 0] | 一致 |
|
||||||
|
| `contentBit` | 2145488608 | 2145488608 | 一致 |
|
||||||
|
| `playCount` | 1258 | 1259 | **不同** |
|
||||||
|
| `currentPlayCount` | 186 | 187 | **不同** |
|
||||||
|
| `renameCredit` | 0 | 0 | 一致 |
|
||||||
|
| `mapStock` | 0 | 0 | 一致 |
|
||||||
|
| `eventWatchedDate` | "2026-10-05 00:19:37.0" | "2026-10-05 17:55:38.0" | **不同** |
|
||||||
|
| `lastGameId` | "SDGB" | "SDGB" | 一致 |
|
||||||
|
| `lastRomVersion` | "1.56.00" | "1.56.00" | 一致 |
|
||||||
|
| `lastDataVersion` | "1.55.05" | "1.55.05" | 一致 |
|
||||||
|
| `lastLoginDate` | "2026-10-05 00:19:37.0" | "2026-10-05 17:55:38.0" | **不同** |
|
||||||
|
| `lastPlayDate` | "2026-10-05 00:20:57.0" | "2026-10-05 17:55:38.0" | **不同** |
|
||||||
|
| `lastPlayCredit` | 1 | 1 | 一致 |
|
||||||
|
| `lastPlayMode` | 0 | 0 | 一致 |
|
||||||
|
| `lastPlaceId` | 1000 | 1000 | 一致 |
|
||||||
|
| `lastPlaceName` | "测试店铺" | "测试店铺" | 一致 |
|
||||||
|
| `lastAllNetId` | 0 | 0 | 一致 |
|
||||||
|
| `lastRegionId` | 5 | 5 | 一致 |
|
||||||
|
| `lastRegionName` | "测试区域" | "测试区域" | 一致 |
|
||||||
|
| `lastClientId` | "TESTCLIENT1" | "TESTCLIENT1" | 一致 |
|
||||||
|
| `lastCountryCode` | "CHN" | "CHN" | 一致 |
|
||||||
|
| `lastSelectEMoney` | 0 | 0 | 一致 |
|
||||||
|
| `lastSelectTicket` | 0 | 0 | 一致 |
|
||||||
|
| `lastSelectCourse` | 552304 | 552304 | 一致 |
|
||||||
|
| `lastCountCourse` | 0 | 0 | 一致 |
|
||||||
|
| `firstGameId` | "SDGB" | "SDGB" | 一致 |
|
||||||
|
| `firstRomVersion` | "1.41.00" | "1.41.00" | 一致 |
|
||||||
|
| `firstDataVersion` | "1.40.08" | "1.40.08" | 一致 |
|
||||||
|
| `firstPlayDate` | "2024-12-31 21:25:58" | "2024-12-31 21:25:58" | 一致 |
|
||||||
|
| `compatibleCmVersion` | "1.55.00" | "1.55.00" | 一致 |
|
||||||
|
| `dailyBonusDate` | "1970-01-01 08:00:00" | "1970-01-01 08:00:00" | 一致 |
|
||||||
|
| `dailyCourseBonusDate` | "2026-07-13 00:00:00" | "2026-07-13 00:00:00" | 一致 |
|
||||||
|
| `lastPairLoginDate` | "2026-10-04 18:05:18" | "2026-10-04 18:05:18" | 一致 |
|
||||||
|
| `lastTrialPlayDate` | "2026-10-03 21:07:50" | "2026-10-03 21:07:50" | 一致 |
|
||||||
|
| `playVsCount` | 0 | 0 | 一致 |
|
||||||
|
| `playSyncCount` | 0 | 0 | 一致 |
|
||||||
|
| `winCount` | 0 | 0 | 一致 |
|
||||||
|
| `helpCount` | 0 | 0 | 一致 |
|
||||||
|
| `comboCount` | 0 | 0 | 一致 |
|
||||||
|
| `totalDeluxscore` | 1869771 | 1869771 | 一致 |
|
||||||
|
| `totalBasicDeluxscore` | 1274 | 1274 | 一致 |
|
||||||
|
| `totalAdvancedDeluxscore` | 6566 | 6566 | 一致 |
|
||||||
|
| `totalExpertDeluxscore` | 470976 | 470976 | 一致 |
|
||||||
|
| `totalMasterDeluxscore` | 1236693 | 1236693 | 一致 |
|
||||||
|
| `totalReMasterDeluxscore` | 154262 | 154262 | 一致 |
|
||||||
|
| `totalSync` | 1112 | 1112 | 一致 |
|
||||||
|
| `totalBasicSync` | 5 | 5 | 一致 |
|
||||||
|
| `totalAdvancedSync` | 5 | 5 | 一致 |
|
||||||
|
| `totalExpertSync` | 425 | 425 | 一致 |
|
||||||
|
| `totalMasterSync` | 604 | 604 | 一致 |
|
||||||
|
| `totalReMasterSync` | 73 | 73 | 一致 |
|
||||||
|
| `totalAchievement` | 975613200 | 975613200 | 一致 |
|
||||||
|
| `totalBasicAchievement` | 2984753 | 2984753 | 一致 |
|
||||||
|
| `totalAdvancedAchievement` | 7755815 | 7755815 | 一致 |
|
||||||
|
| `totalExpertAchievement` | 315483810 | 315483810 | 一致 |
|
||||||
|
| `totalMasterAchievement` | 578226016 | 578226016 | 一致 |
|
||||||
|
| `totalReMasterAchievement` | 71162806 | 71162806 | 一致 |
|
||||||
|
| `playerOldRating` | 10927 | 10927 | 一致 |
|
||||||
|
| `playerNewRating` | 4606 | 4606 | 一致 |
|
||||||
|
| `banState` | 0 | 0 | 一致 |
|
||||||
|
| `friendRegistSkip` | 0 | 0 | 一致 |
|
||||||
|
| `dateTime` | 1791134329 | 1791194138 | **不同** |
|
||||||
|
|
||||||
|
真机 82 字段,实跑 82 字段,命中 76/82
|
||||||
|
|
||||||
|
## 差分节点明细
|
||||||
|
|
||||||
|
### `userMusicDetailList`
|
||||||
|
|
||||||
|
真机:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"musicId": 143,
|
||||||
|
"level": 4,
|
||||||
|
"playCount": 8,
|
||||||
|
"achievement": 0,
|
||||||
|
"comboStatus": 0,
|
||||||
|
"syncStatus": 0,
|
||||||
|
"deluxscoreMax": 0,
|
||||||
|
"scoreRank": 0,
|
||||||
|
"extNum1": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
实跑:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"musicId": 143,
|
||||||
|
"level": 4,
|
||||||
|
"playCount": 9,
|
||||||
|
"achievement": 0,
|
||||||
|
"comboStatus": 0,
|
||||||
|
"syncStatus": 0,
|
||||||
|
"deluxscoreMax": 0,
|
||||||
|
"scoreRank": 0,
|
||||||
|
"extNum1": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `userMapList`
|
||||||
|
|
||||||
|
真机:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"mapId": 550003,
|
||||||
|
"distance": 1435000,
|
||||||
|
"isLock": false,
|
||||||
|
"isClear": false,
|
||||||
|
"isComplete": false,
|
||||||
|
"unlockFlag": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
实跑:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"mapId": 550003,
|
||||||
|
"distance": 1446000,
|
||||||
|
"isLock": false,
|
||||||
|
"isClear": false,
|
||||||
|
"isComplete": false,
|
||||||
|
"unlockFlag": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `userCharacterList`
|
||||||
|
|
||||||
|
真机:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"characterId": 101,
|
||||||
|
"level": 4456,
|
||||||
|
"awakening": 5,
|
||||||
|
"useCount": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"characterId": 395,
|
||||||
|
"level": 3801,
|
||||||
|
"awakening": 5,
|
||||||
|
"useCount": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"characterId": 200401,
|
||||||
|
"level": 135,
|
||||||
|
"awakening": 3,
|
||||||
|
"useCount": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"characterId": 400401,
|
||||||
|
"level": 135,
|
||||||
|
"awakening": 3,
|
||||||
|
"useCount": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"characterId": 400402,
|
||||||
|
"level": 135,
|
||||||
|
"awakening": 3,
|
||||||
|
"useCount": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
实跑:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"characterId": 395,
|
||||||
|
"useCount": 0,
|
||||||
|
"level": 3802,
|
||||||
|
"awakening": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"characterId": 101,
|
||||||
|
"useCount": 0,
|
||||||
|
"level": 4457,
|
||||||
|
"awakening": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"characterId": 400402,
|
||||||
|
"useCount": 0,
|
||||||
|
"level": 136,
|
||||||
|
"awakening": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"characterId": 400401,
|
||||||
|
"useCount": 0,
|
||||||
|
"level": 136,
|
||||||
|
"awakening": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"characterId": 200401,
|
||||||
|
"useCount": 0,
|
||||||
|
"level": 136,
|
||||||
|
"awakening": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `userChargeList`
|
||||||
|
|
||||||
|
真机:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"chargeId": 2,
|
||||||
|
"stock": 0,
|
||||||
|
"purchaseDate": "2026-07-14 18:33:37",
|
||||||
|
"validDate": "2026-10-12 04:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"chargeId": 3,
|
||||||
|
"stock": 0,
|
||||||
|
"purchaseDate": "2026-10-02 10:49:23",
|
||||||
|
"validDate": "2026-12-31 04:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"chargeId": 5,
|
||||||
|
"stock": 0,
|
||||||
|
"purchaseDate": "2026-10-04 18:22:10",
|
||||||
|
"validDate": "2027-01-02 04:00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
实跑:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"chargeId": 2,
|
||||||
|
"stock": 0,
|
||||||
|
"purchaseDate": "2026-07-14 18:33:37",
|
||||||
|
"validDate": "2026-10-12 04:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"chargeId": 3,
|
||||||
|
"stock": 0,
|
||||||
|
"purchaseDate": "2026-10-02 10:49:23",
|
||||||
|
"validDate": "2026-12-31 04:00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"chargeId": 5,
|
||||||
|
"stock": 0,
|
||||||
|
"purchaseDate": "2026-10-04 18:22:10",
|
||||||
|
"validDate": "2027-01-02 04:00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `userFavoriteList`
|
||||||
|
|
||||||
|
真机:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"itemKind": 3,
|
||||||
|
"itemIdList": [
|
||||||
|
559501
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"itemKind": 1,
|
||||||
|
"itemIdList": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"itemKind": 2,
|
||||||
|
"itemIdList": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"itemKind": 10,
|
||||||
|
"itemIdList": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"itemKind": 11,
|
||||||
|
"itemIdList": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
实跑:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"itemKind": 3,
|
||||||
|
"itemIdList": [
|
||||||
|
559501
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"itemKind": 1,
|
||||||
|
"itemIdList": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"itemKind": 2,
|
||||||
|
"itemIdList": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"itemKind": 10,
|
||||||
|
"itemIdList": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"itemKind": 11,
|
||||||
|
"itemIdList": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 两个对象节点
|
||||||
|
|
||||||
|
### `userWeeklyData`
|
||||||
|
|
||||||
|
真机:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lastLoginWeek": "2026-09-28 04:00:00",
|
||||||
|
"beforeLoginWeek": "2026-09-21 04:00:00",
|
||||||
|
"friendBonusFlag": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实跑:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lastLoginWeek": "2026-09-28 04:00:00",
|
||||||
|
"beforeLoginWeek": "2026-09-21 04:00:00",
|
||||||
|
"friendBonusFlag": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `user2pPlaylog`
|
||||||
|
|
||||||
|
真机:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId1": 0,
|
||||||
|
"userId2": 0,
|
||||||
|
"userName1": "",
|
||||||
|
"userName2": "",
|
||||||
|
"regionId": 0,
|
||||||
|
"placeId": 0,
|
||||||
|
"user2pPlaylogDetailList": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实跑:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId1": 0,
|
||||||
|
"userId2": 0,
|
||||||
|
"userName1": "",
|
||||||
|
"userName2": "",
|
||||||
|
"regionId": 0,
|
||||||
|
"placeId": 0,
|
||||||
|
"user2pPlaylogDetailList": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## userGamePlaylogList
|
||||||
|
|
||||||
|
真机:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"playlogId": 7692848101484135861,
|
||||||
|
"version": "1.56.00",
|
||||||
|
"playDate": "2026-10-05 00:20:57.0",
|
||||||
|
"playMode": 0,
|
||||||
|
"useTicketId": -1,
|
||||||
|
"playCredit": 1,
|
||||||
|
"playTrack": 1,
|
||||||
|
"clientId": "TESTCLIENT1",
|
||||||
|
"isPlayTutorial": false,
|
||||||
|
"isEventMode": false,
|
||||||
|
"isNewFree": false,
|
||||||
|
"playCount": 8,
|
||||||
|
"playSpecial": 55951502,
|
||||||
|
"playOtherUserId": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
实跑:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"playlogId": 7693120247791879616,
|
||||||
|
"version": "1.56.00",
|
||||||
|
"playDate": "2026-10-05 17:55:38.0",
|
||||||
|
"playMode": 0,
|
||||||
|
"useTicketId": -1,
|
||||||
|
"playCredit": 1,
|
||||||
|
"playTrack": 1,
|
||||||
|
"clientId": "TESTCLIENT1",
|
||||||
|
"isPlayTutorial": false,
|
||||||
|
"isEventMode": false,
|
||||||
|
"isNewFree": false,
|
||||||
|
"playCount": 8,
|
||||||
|
"playSpecial": 16417894,
|
||||||
|
"playOtherUserId": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## userPlaylogList[0] 逐字段对比(110 字段)
|
||||||
|
|
||||||
|
| # | 字段 | 真机 | 实跑 | 一致 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `userId` | 0 | 0 | 一致 |
|
||||||
|
| 2 | `orderId` | 0 | 0 | 一致 |
|
||||||
|
| 3 | `playlogId` | 7692848101484135861 | 7693120247791879616 | **不同** |
|
||||||
|
| 4 | `version` | 1056000 | 1056000 | 一致 |
|
||||||
|
| 5 | `placeId` | 1000 | 1000 | 一致 |
|
||||||
|
| 6 | `placeName` | "测试店铺" | "测试店铺" | 一致 |
|
||||||
|
| 7 | `loginDate` | 1791130777 | 1791194138 | **不同** |
|
||||||
|
| 8 | `playDate` | "2026-10-04" | "2026-10-05" | **不同** |
|
||||||
|
| 9 | `userPlayDate` | "2026-10-05 00:20:30.0" | "2026-10-05 17:55:38.0" | **不同** |
|
||||||
|
| 10 | `type` | 0 | 0 | 一致 |
|
||||||
|
| 11 | `musicId` | 143 | 143 | 一致 |
|
||||||
|
| 12 | `level` | 4 | 4 | 一致 |
|
||||||
|
| 13 | `trackNo` | 1 | 1 | 一致 |
|
||||||
|
| 14 | `vsMode` | 0 | 0 | 一致 |
|
||||||
|
| 15 | `vsUserName` | "" | "" | 一致 |
|
||||||
|
| 16 | `vsStatus` | 0 | 0 | 一致 |
|
||||||
|
| 17 | `vsUserRating` | 0 | 0 | 一致 |
|
||||||
|
| 18 | `vsUserAchievement` | 0 | 0 | 一致 |
|
||||||
|
| 19 | `vsUserGradeRank` | 0 | 0 | 一致 |
|
||||||
|
| 20 | `vsRank` | 0 | 0 | 一致 |
|
||||||
|
| 21 | `playerNum` | 1 | 1 | 一致 |
|
||||||
|
| 22 | `playedUserId1` | 0 | 0 | 一致 |
|
||||||
|
| 23 | `playedUserName1` | "" | "" | 一致 |
|
||||||
|
| 24 | `playedMusicLevel1` | 0 | 0 | 一致 |
|
||||||
|
| 25 | `playedUserId2` | 0 | 0 | 一致 |
|
||||||
|
| 26 | `playedUserName2` | "" | "" | 一致 |
|
||||||
|
| 27 | `playedMusicLevel2` | 0 | 0 | 一致 |
|
||||||
|
| 28 | `playedUserId3` | 0 | 0 | 一致 |
|
||||||
|
| 29 | `playedUserName3` | "" | "" | 一致 |
|
||||||
|
| 30 | `playedMusicLevel3` | 0 | 0 | 一致 |
|
||||||
|
| 31 | `characterId1` | 395 | 395 | 一致 |
|
||||||
|
| 32 | `characterLevel1` | 3800 | 3800 | 一致 |
|
||||||
|
| 33 | `characterAwakening1` | 5 | 5 | 一致 |
|
||||||
|
| 34 | `characterId2` | 101 | 101 | 一致 |
|
||||||
|
| 35 | `characterLevel2` | 4455 | 4455 | 一致 |
|
||||||
|
| 36 | `characterAwakening2` | 5 | 5 | 一致 |
|
||||||
|
| 37 | `characterId3` | 400402 | 400402 | 一致 |
|
||||||
|
| 38 | `characterLevel3` | 134 | 134 | 一致 |
|
||||||
|
| 39 | `characterAwakening3` | 3 | 3 | 一致 |
|
||||||
|
| 40 | `characterId4` | 400401 | 400401 | 一致 |
|
||||||
|
| 41 | `characterLevel4` | 134 | 134 | 一致 |
|
||||||
|
| 42 | `characterAwakening4` | 3 | 3 | 一致 |
|
||||||
|
| 43 | `characterId5` | 200401 | 200401 | 一致 |
|
||||||
|
| 44 | `characterLevel5` | 134 | 134 | 一致 |
|
||||||
|
| 45 | `characterAwakening5` | 3 | 3 | 一致 |
|
||||||
|
| 46 | `achievement` | 0 | 0 | 一致 |
|
||||||
|
| 47 | `deluxscore` | 0 | 0 | 一致 |
|
||||||
|
| 48 | `scoreRank` | 0 | 0 | 一致 |
|
||||||
|
| 49 | `maxCombo` | 0 | 0 | 一致 |
|
||||||
|
| 50 | `totalCombo` | 655 | 655 | 一致 |
|
||||||
|
| 51 | `maxSync` | 0 | 0 | 一致 |
|
||||||
|
| 52 | `totalSync` | 0 | 0 | 一致 |
|
||||||
|
| 53 | `tapCriticalPerfect` | 0 | 0 | 一致 |
|
||||||
|
| 54 | `tapPerfect` | 0 | 0 | 一致 |
|
||||||
|
| 55 | `tapGreat` | 0 | 0 | 一致 |
|
||||||
|
| 56 | `tapGood` | 0 | 0 | 一致 |
|
||||||
|
| 57 | `tapMiss` | 441 | 441 | 一致 |
|
||||||
|
| 58 | `holdCriticalPerfect` | 0 | 0 | 一致 |
|
||||||
|
| 59 | `holdPerfect` | 0 | 0 | 一致 |
|
||||||
|
| 60 | `holdGreat` | 0 | 0 | 一致 |
|
||||||
|
| 61 | `holdGood` | 0 | 0 | 一致 |
|
||||||
|
| 62 | `holdMiss` | 19 | 19 | 一致 |
|
||||||
|
| 63 | `slideCriticalPerfect` | 0 | 0 | 一致 |
|
||||||
|
| 64 | `slidePerfect` | 0 | 0 | 一致 |
|
||||||
|
| 65 | `slideGreat` | 0 | 0 | 一致 |
|
||||||
|
| 66 | `slideGood` | 0 | 0 | 一致 |
|
||||||
|
| 67 | `slideMiss` | 192 | 192 | 一致 |
|
||||||
|
| 68 | `touchCriticalPerfect` | 0 | 0 | 一致 |
|
||||||
|
| 69 | `touchPerfect` | 0 | 0 | 一致 |
|
||||||
|
| 70 | `touchGreat` | 0 | 0 | 一致 |
|
||||||
|
| 71 | `touchGood` | 0 | 0 | 一致 |
|
||||||
|
| 72 | `touchMiss` | 0 | 0 | 一致 |
|
||||||
|
| 73 | `breakCriticalPerfect` | 0 | 0 | 一致 |
|
||||||
|
| 74 | `breakPerfect` | 0 | 0 | 一致 |
|
||||||
|
| 75 | `breakGreat` | 0 | 0 | 一致 |
|
||||||
|
| 76 | `breakGood` | 0 | 0 | 一致 |
|
||||||
|
| 77 | `breakMiss` | 3 | 3 | 一致 |
|
||||||
|
| 78 | `isTap` | true | true | 一致 |
|
||||||
|
| 79 | `isHold` | true | true | 一致 |
|
||||||
|
| 80 | `isSlide` | true | true | 一致 |
|
||||||
|
| 81 | `isTouch` | false | false | 一致 |
|
||||||
|
| 82 | `isBreak` | true | true | 一致 |
|
||||||
|
| 83 | `isCriticalDisp` | true | true | 一致 |
|
||||||
|
| 84 | `isFastLateDisp` | true | true | 一致 |
|
||||||
|
| 85 | `fastCount` | 0 | 0 | 一致 |
|
||||||
|
| 86 | `lateCount` | 0 | 0 | 一致 |
|
||||||
|
| 87 | `isAchieveNewRecord` | false | false | 一致 |
|
||||||
|
| 88 | `isDeluxscoreNewRecord` | false | false | 一致 |
|
||||||
|
| 89 | `comboStatus` | 0 | 0 | 一致 |
|
||||||
|
| 90 | `syncStatus` | 0 | 0 | 一致 |
|
||||||
|
| 91 | `isClear` | false | false | 一致 |
|
||||||
|
| 92 | `beforeRating` | 15000 | 15000 | 一致 |
|
||||||
|
| 93 | `afterRating` | 15000 | 15000 | 一致 |
|
||||||
|
| 94 | `beforeGrade` | 0 | 0 | 一致 |
|
||||||
|
| 95 | `afterGrade` | 0 | 0 | 一致 |
|
||||||
|
| 96 | `afterGradeRank` | 1 | 1 | 一致 |
|
||||||
|
| 97 | `beforeDeluxRating` | 15000 | 15000 | 一致 |
|
||||||
|
| 98 | `afterDeluxRating` | 15000 | 15000 | 一致 |
|
||||||
|
| 99 | `isPlayTutorial` | false | false | 一致 |
|
||||||
|
| 100 | `isEventMode` | false | false | 一致 |
|
||||||
|
| 101 | `isFreedomMode` | false | false | 一致 |
|
||||||
|
| 102 | `playMode` | 0 | 0 | 一致 |
|
||||||
|
| 103 | `isNewFree` | false | false | 一致 |
|
||||||
|
| 104 | `trialPlayAchievement` | -1 | -1 | 一致 |
|
||||||
|
| 105 | `extNum1` | 0 | 0 | 一致 |
|
||||||
|
| 106 | `extNum2` | 0 | 0 | 一致 |
|
||||||
|
| 107 | `extNum4` | 2022 | 2022 | 一致 |
|
||||||
|
| 108 | `extBool1` | false | false | 一致 |
|
||||||
|
| 109 | `extBool2` | false | false | 一致 |
|
||||||
|
| 110 | `extBool3` | false | false | 一致 |
|
||||||
|
|
||||||
|
命中 106/110
|
||||||
|
|
||||||
|
## 汇总
|
||||||
|
|
||||||
|
- userData 字段命中:76/82
|
||||||
|
- playlog 字段命中:106/110
|
||||||
|
- 节点形态命中:36/36
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
# 上传成绩(UpsertUserAll)开发文档
|
||||||
|
|
||||||
|
> 对应 `api/upsert_user_all.py` 与 `api/upsert_snapshot.py`。
|
||||||
|
> **判据 = `source/2026-10-05.log:123` 的真机跑通报文 + `source/Assembly-CSharp/` 反编译源码。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、这是什么
|
||||||
|
|
||||||
|
`UpsertUserAllApi` 是**唯一一个有副作用的写接口**:把一局游玩的完整结算数据
|
||||||
|
(成绩明细 + 全量用户数据快照)写进服务器。发出去收不回来。
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 逻辑 API 名 | `UpsertUserAllApi` |
|
||||||
|
| 实际 URL 尾段 | `md5("UpsertUserAllApiMaimaiChn" + "8bF76dE9")` |
|
||||||
|
| 超时 | 60 秒(`NetConfig.TimeOutInMSecLong`) |
|
||||||
|
| 副作用 | ★ 有,不可撤销 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、顶层请求结构(7 字段)
|
||||||
|
|
||||||
|
依据 `PacketUpsertUserAll.cs:22-35`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
UserAllRequestVO(
|
||||||
|
userId = user_id,
|
||||||
|
playlogId = login.loginId, # ★ 必须 == UserLoginApi 的 loginId
|
||||||
|
isEventMode = False,
|
||||||
|
isFreePlay = False,
|
||||||
|
loginDateTime = login.loginDateTime, # ★ 必须 == UserLoginApi 的 loginDateTime
|
||||||
|
userPlaylogList = [...本局成绩...],
|
||||||
|
upsertUserAll = {...36 个节点...},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:`{"returnCode": 1, "apiName": "com.sega.maimai.api.UpsertUserAllApi"}`。
|
||||||
|
|
||||||
|
> ⚠ `playlogId` 与 `loginDateTime` **不是本接口产生的**,它们来自 `UserLoginApi`。
|
||||||
|
> 发错登录 ID 服务端会直接拒。中途换了登录会话必须重新登录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、upsertUserAll 的 36 个节点
|
||||||
|
|
||||||
|
依据 `Net.VO.Mai2/UserAll.cs`,并用真机报文逐节点核对。**四种形态**:
|
||||||
|
|
||||||
|
| 类别 | 数量 | C# 类型 | JSON 形态 |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| 单元素数组 | 4 | UserDetail[] / UserExtend[] / UserOption[] / UserRating[] | 数组,恒 1 元素 |
|
||||||
|
| **对象(非数组)** | 2 | UserWeeklyData / User2pPlaylog | `{}` ★ |
|
||||||
|
| 普通数组 | 19 | Xxx[] | `[]` |
|
||||||
|
| **位串** | 11 | string | 定长字符串 |
|
||||||
|
|
||||||
|
合计 4 + 2 + 19 + 11 = **36**。
|
||||||
|
|
||||||
|
### 3.1 ★ 两个对象节点
|
||||||
|
|
||||||
|
`userWeeklyData` 与 `user2pPlaylog` **不是数组**。传数组会被 `coerce_node_value()`
|
||||||
|
直接拦下并抛 `PatchError`(这是刻意设计的防呆)。
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"userWeeklyData": { "lastLoginWeek": "...", "beforeLoginWeek": "...", "friendBonusFlag": true }
|
||||||
|
"user2pPlaylog" : { "userId1": 0, ..., "user2pPlaylogDetailList": [] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 ★ 位串长度 == 对应数组条数
|
||||||
|
|
||||||
|
11 个 `isNew*` 与同名数组一一对应。**长度不符服务端会误判「哪些是新增」**。
|
||||||
|
`build_user_all()` 末尾会自动补齐(`sync_bits_to_arrays()`)。
|
||||||
|
|
||||||
|
| 位串 | 对应数组 |
|
||||||
|
|---|---|
|
||||||
|
| `isNewCharacterList` | userCharacterList |
|
||||||
|
| `isNewMapList` | userMapList |
|
||||||
|
| `isNewLoginBonusList` | userLoginBonusList |
|
||||||
|
| `isNewItemList` | userItemList |
|
||||||
|
| `isNewMusicDetailList` | userMusicDetailList |
|
||||||
|
| `isNewCourseList` | userCourseList |
|
||||||
|
| `isNewFavoriteList` | userFavoriteList |
|
||||||
|
| `isNewFriendSeasonRankingList` | userFriendSeasonRankingList |
|
||||||
|
| `isNewUserIntimateList` | userIntimateList |
|
||||||
|
| `isNewFavoritemusicList` | userFavoritemusicList |
|
||||||
|
| `isNewKaleidxScopeList` | userKaleidxScopeList |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、★★ 核心:快照基线 + 本局演算 + 差分
|
||||||
|
|
||||||
|
### 4.1 真机对照(同一账号的 Get 侧 vs Upsert 侧)
|
||||||
|
|
||||||
|
| 节点 | Get 侧(登录时) | Upsert 侧(结算时) | 结论 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `userData.playCount` | 1257 | **1258** | +1 |
|
||||||
|
| `userData.currentPlayCount` | 185 | **186** | +1 |
|
||||||
|
| `userData.lastPlayDate` | 2026-10-04 18:46:31 | **2026-10-05 00:20:57** | 更新 |
|
||||||
|
| `userData.lastPlayMode` | 2 | **0** | 归 0 |
|
||||||
|
| `userData.lastLoginDate` | 2026-10-04 18:34:22 | **2026-10-05 00:19:37** | 本次登录 |
|
||||||
|
| `userData.dateTime` | null | **1791134329** | 登录请求的 dateTime |
|
||||||
|
| `userData.lastPlaceId` | 0 | **1000** | 当前店铺 |
|
||||||
|
| `userData.lastRegionId` | 11 | **5** | 当前区域 |
|
||||||
|
| `userMusicDetailList` | 739 组 | **1 条** | 只回传本局那条,playCount 7→8 |
|
||||||
|
| `userMapList` | 104 条 | **1 条** | 只回传变化那条,distance +11000 |
|
||||||
|
| `userCharacterList` | 312 条 | **5 条** | 组队 5 只,各 +1 级 |
|
||||||
|
| `userItemList` | 1070 条 | **0 条** | 无变化 |
|
||||||
|
| `userCourseList` | 16 条 | **0 条** | 无变化 |
|
||||||
|
| `userChargeList` | 3 条 | **3 条** | ★ **整表回传,不做差分** |
|
||||||
|
| `userFavoriteList` | 5 类 | **5 类** | ★ 不做剔除,恒返回全部 |
|
||||||
|
|
||||||
|
### 4.2 ★★ 为什么必须做差分(这是「returnCode=1 但没落库」的根因)
|
||||||
|
|
||||||
|
**服务器对 `userData` 是整表覆盖语义** —— 把 Get 下来的旧值原样发回去,
|
||||||
|
写入后「看起来什么都没发生」。
|
||||||
|
|
||||||
|
**差分节点若为空,服务端也看不出本局有任何变化**,整包等于没生效。
|
||||||
|
|
||||||
|
所以本模块的流程是:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 从 snapshot(登录后抓的 Get 响应)建立基线
|
||||||
|
2. 按本局数据演算出新状态,并对差分节点算 (新 - 基线)
|
||||||
|
3. 用 patch 做精确覆盖
|
||||||
|
4. 生成 11 条位串(长度自动对齐对应数组)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 差分算法(复刻 `VOExtensions.BuildListData`)
|
||||||
|
|
||||||
|
三个要点(源码 `VOExtensions.cs:309-324`):
|
||||||
|
|
||||||
|
1. `nsrc.Except(osrc)` 退化成**全字段逐字段比较** → 任一字段变了整条就回传
|
||||||
|
2. `compare` 委托**只用于算位串**,不参与剔除
|
||||||
|
3. 位串 `"0"` = 存量已有同主键(更新)、`"1"` = 全新(新增)
|
||||||
|
|
||||||
|
### 4.4 收藏夹是特例
|
||||||
|
|
||||||
|
`build_favorite_delta()` 复刻 `VOExtensions.cs:345-367`:**不做剔除,恒返回完整 5 条**,
|
||||||
|
位串按真机 `"22212"` 反推:
|
||||||
|
|
||||||
|
- `"2"` = 服务器侧有对应 itemKind
|
||||||
|
- `"1"` = 服务器侧查不到(真机 `itemKind=10` 的 Get 响应回的是 `{"itemKind":null}`)
|
||||||
|
|
||||||
|
> 因此**必须用未归一化的原始 Get 响应作基线**。`user_favorite.py` 会把原始副本
|
||||||
|
> 放在返回值的 `_raw` 里,`upsert_snapshot.py` 把它存成 `_raw_favorite`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、userPlaylogList(110 字段成绩明细)
|
||||||
|
|
||||||
|
依据 `Net.VO.Mai2/UserPlaylog.cs`,实际声明 **110 个** `public` 字段。
|
||||||
|
|
||||||
|
### 5.1 ★ 字段必须全部发送
|
||||||
|
|
||||||
|
把真机报文的 `userPlaylogList[0]` 与 C# 声明做集合比对:
|
||||||
|
|
||||||
|
| 比对方向 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| C# 声明但报文未出现 | **0 个** |
|
||||||
|
| 报文有但 C# 未声明 | **0 个** |
|
||||||
|
|
||||||
|
**能精简的是「填写的内容要真实」,而不是删字段。**
|
||||||
|
|
||||||
|
### 5.2 值之间的硬性关系(`validate_user_playlog()` 会查)
|
||||||
|
|
||||||
|
| 关系 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 各类型判定数之和 == `totalCombo` | totalCombo 是**该谱面 note 总数** |
|
||||||
|
| `maxCombo` <= 判定总数 | 全连时二者相等 |
|
||||||
|
| `achievement > 0` 时不应全是 Miss | 否则数值矛盾 |
|
||||||
|
| `isClear=True` 时 `achievement` 不能为 0 | 否则矛盾 |
|
||||||
|
| `comboStatus=1`(全连)时 `maxCombo == 判定总数` | |
|
||||||
|
| `extNum4` == `userExtend.categoryIndex` | 曲种索引冗余两处 |
|
||||||
|
|
||||||
|
### 5.3 ★ 日期字段的真实语义(`TimeManager.cs`)
|
||||||
|
|
||||||
|
真机 `TimeManager` 的日期处理有三个容易搞错的点:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Manager/TimeManager.cs:9
|
||||||
|
public static readonly TimeSpan CSToffset = new TimeSpan(8, 0, 0);
|
||||||
|
|
||||||
|
// :33-37 把 Unix 秒转成"北京时间墙上时间"
|
||||||
|
public static DateTime GetDateTime(long unixTime)
|
||||||
|
=> DateTimeOffset.FromUnixTimeSeconds(unixTime).DateTime.Add(CSToffset);
|
||||||
|
|
||||||
|
// :77-85 ★ 游玩日志日不是自然日:7 点前算前一天
|
||||||
|
public static DateTime ToLogDateTime(DateTime dateTime, int hour = 0)
|
||||||
|
{
|
||||||
|
DateTime result = new(dateTime.Year, dateTime.Month, dateTime.Day, hour, 0, 0);
|
||||||
|
if (7 > dateTime.Hour) return result.AddDays(-1.0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
对应到 `VOExtensions.cs:1290-1292`:
|
||||||
|
|
||||||
|
| 字段 | 算法 | 真机值 |
|
||||||
|
|---|---|---|
|
||||||
|
| `loginDate` | `GetUnixTime(LastLoginDate)` → **标准 Unix 秒** | 1791130777 |
|
||||||
|
| `playDate` | `GetLogDateString(playTime)` → **日志日**(7 点前回退一天) | "2026-10-04" |
|
||||||
|
| `userPlayDate` | `GetDateString(playTime)` → 北京时间墙上串 | "2026-10-05 00:20:30.0" |
|
||||||
|
|
||||||
|
★ 注意 `loginDate` 是**标准 Unix 秒**,不是 `loginDateTime`:
|
||||||
|
|
||||||
|
```
|
||||||
|
loginDateTime = 1791159576 (「墙上时间当 UTC」)
|
||||||
|
loginDate = 1791130777 (= loginDateTime - 28800,标准 Unix 秒)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 bool 字段陷阱
|
||||||
|
|
||||||
|
`extBool1/2/3` 在 C# 里是 `bool`,但**名字不带 `is` 前缀**。
|
||||||
|
用 `name.startswith("is")` 判断会漏掉它们,导致序列化成 `0` 而不是 `false`。
|
||||||
|
本模块用显式的 `PLAYLOG_BOOL_FIELDS` 集合判定。
|
||||||
|
|
||||||
|
同理,字符串字段(`vsUserName` / `playedUserNameN` 等)默认值必须是 `""` 而非 `0`,
|
||||||
|
见 `PLAYLOG_STRING_FIELDS`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、userGamePlaylogList
|
||||||
|
|
||||||
|
真机样本(13 字段):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"playlogId":7692848101484135861,"version":"1.56.00","playDate":"2026-10-05 00:20:57.0",
|
||||||
|
"playMode":0,"useTicketId":-1,"playCredit":1,"playTrack":1,"clientId":"TESTCLIENT1",
|
||||||
|
"isPlayTutorial":false,"isEventMode":false,"isNewFree":false,"playCount":8,
|
||||||
|
"playSpecial":55951502,"playOtherUserId":0}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.1 `playSpecial` 是随机位掩码
|
||||||
|
|
||||||
|
源码 `VOExtensions.cs:846-855`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
int max = 1037933;
|
||||||
|
int num2 = Random.Range(1, max) * 2069;
|
||||||
|
num2 += GameManager.CalcSpecialNum();
|
||||||
|
int num3 = 0;
|
||||||
|
for (int i = 0; i < 32; i++) { num3 <<= 1; num3 += num2 % 2; num2 >>= 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
恒传 0 不像真机生成的值,`calc_play_special()` 复刻了该算法。
|
||||||
|
|
||||||
|
### 6.2 `playCredit` 与 `useTicketId`
|
||||||
|
|
||||||
|
源码 `VOExtensions.cs:838-845`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
int num = Credit.GameCostEnoughPlay(); // 投币成本
|
||||||
|
int useTicketId = TicketManager.GetUseTicketId(playerId);
|
||||||
|
int ticketCredit = TicketManager.GetTicketCredit(useTicketId);
|
||||||
|
int playCredit = num + ticketCredit; // 本局折合多少 credit
|
||||||
|
```
|
||||||
|
|
||||||
|
`useTicketId = -1` 表示**不使用票**(`TicketManager.cs:47` 初始化即 -1)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、字段裁剪(Get 侧 → Upsert 侧要丢掉的)
|
||||||
|
|
||||||
|
| 节点 | 丢弃字段 |
|
||||||
|
|---|---|
|
||||||
|
| `userData` | `friendCode`、`nameplateId`、`trophyId`、`cmLastEmoneyCredit`、`cmLastEmoneyBrand` |
|
||||||
|
| `userOption` | `tempoVolume` |
|
||||||
|
| `userCharacterList` | 只保留 `characterId`/`level`/`awakening`/`useCount` |
|
||||||
|
| `userMusicDetailList` | `extNum2` |
|
||||||
|
| `userChargeList` | `extNum1` |
|
||||||
|
| `userMissionDataList` / `userWeeklyData` | `userId` |
|
||||||
|
|
||||||
|
### 7.1 `banState` 位置漂移
|
||||||
|
|
||||||
|
- **Get 侧**:`banState` 是 `userData` 的**同级顶层**字段(`UserDetailResponseVO.cs:15`)
|
||||||
|
- **Upsert 侧**:它被**移进了 `userData` 内部**
|
||||||
|
|
||||||
|
`_extract_from_get_responses()` 会自动搬运。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、安全闸门
|
||||||
|
|
||||||
|
| 调用方式 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| `UpsertUserAll(...)` | 抛 `ConfirmRequiredError`,**未发包** |
|
||||||
|
| `UpsertUserAll(..., dry_run=True)` | 只构造并返回报文 |
|
||||||
|
| `UpsertUserAll(..., confirm=True)` | 真实发送 |
|
||||||
|
|
||||||
|
全局开关在 `config.py`:`UPSERT_CONFIRM = False`(改成 True 会永久放行,不建议)。
|
||||||
|
|
||||||
|
### 8.1 发包前冷却(默认 60 秒)
|
||||||
|
|
||||||
|
真实发包前会固定等待 `config.UPSERT_SEND_COOLDOWN_SEC` 秒(**默认 60**),
|
||||||
|
让上报节奏贴近真机 —— 真机从登录到结算上报间隔约 81 秒
|
||||||
|
(`source/2026-10-05.log:25` 登录 → `:123` 上报)。
|
||||||
|
|
||||||
|
| 调用方式 | 是否等待 |
|
||||||
|
|---|---|
|
||||||
|
| `dry_run=True` | 不等待(只构造报文) |
|
||||||
|
| 未开闸门(`confirm` 未置位) | 不等待(直接抛 `ConfirmRequiredError`) |
|
||||||
|
| `confirm=True` | **等待 60 秒**后再发包 |
|
||||||
|
|
||||||
|
实现要点:
|
||||||
|
|
||||||
|
- 冷却位于**「发包前自检」之后** —— 自检有问题时会先看到告警,不必白等 60 秒
|
||||||
|
- 每 10 秒打印一次进度,避免刷屏又能看出没卡死
|
||||||
|
- 调试时跳过:`cooldown_sec=0`
|
||||||
|
|
||||||
|
```
|
||||||
|
上传成绩冷却:等待 60.0 秒后再发送(模拟真机节奏)...
|
||||||
|
冷却中 ... 已等待 10s / 剩 50s
|
||||||
|
冷却中 ... 已等待 20s / 剩 40s
|
||||||
|
冷却中 ... 已等待 30s / 剩 30s
|
||||||
|
冷却中 ... 已等待 40s / 剩 20s
|
||||||
|
冷却中 ... 已等待 50s / 剩 10s
|
||||||
|
冷却结束(共等待 60.0 秒)
|
||||||
|
闸门已放行,开始上传成绩 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
UpsertUserAll(..., confirm=True) # 默认按 config 的 60 秒
|
||||||
|
UpsertUserAll(..., confirm=True, cooldown_sec=0) # 调试时跳过
|
||||||
|
wait_send_cooldown(60, label="上传成绩") # 也可单独调用
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、完整用法
|
||||||
|
|
||||||
|
### 9.1 ★ 推荐写法:一个 `UpsertUserAll` 就够了
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api import (setup_logging, teardown_logging,
|
||||||
|
ParseQRCode, UserPreview, UserLogin, UserLogout, LogoutType,
|
||||||
|
UpsertUserAll)
|
||||||
|
from config import REGION_ID, PLACE_ID
|
||||||
|
|
||||||
|
setup_logging("结算") # ← 只需要这一句日志;api 自己会记
|
||||||
|
try:
|
||||||
|
user_id, token = ParseQRCode(qr)
|
||||||
|
UserPreview(user_id, token)
|
||||||
|
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||||
|
|
||||||
|
# ★ 一个函数跑完:抓快照 -> 组包 -> 前置接口 -> 自检 -> 冷却 -> 发包 -> 落库回查
|
||||||
|
UpsertUserAll(
|
||||||
|
user_id, login.loginId,
|
||||||
|
login_date_time=login.loginDateTime,
|
||||||
|
auth_date_time=login.dateTime,
|
||||||
|
token=token, # 让内部自动抓基线快照
|
||||||
|
dry_run=True, # 先只看报文;真实发送改成 confirm=True
|
||||||
|
)
|
||||||
|
|
||||||
|
UserLogout(user_id, token, login.loginDateTime,
|
||||||
|
logout_type=LogoutType.TestIn,
|
||||||
|
region_id=REGION_ID, place_id=PLACE_ID)
|
||||||
|
finally:
|
||||||
|
teardown_logging()
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意 `snapshot` 都不用自己抓** —— 传了 `token` 且 `auto_snapshot=True`(默认),
|
||||||
|
`UpsertUserAll` 内部会调 `fetch_upsert_snapshot`。
|
||||||
|
|
||||||
|
### 9.2 想手工控制每一步(进阶)
|
||||||
|
|
||||||
|
```python
|
||||||
|
snapshot = fetch_upsert_snapshot(user_id, token=token) # 自己抓
|
||||||
|
play = build_play_from_config() # 自己改本局数据
|
||||||
|
play.characters = derive_play_characters(snapshot, play) # 自己推角色
|
||||||
|
|
||||||
|
UpsertUserAll(user_id, login.loginId,
|
||||||
|
login_date_time=login.loginDateTime,
|
||||||
|
snapshot=snapshot, play=play,
|
||||||
|
pre_new_item_list=False, # 不调前置接口
|
||||||
|
verify_landing=False, # 不做落库回查
|
||||||
|
dry_run=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 想额外塞节点:`extra_data`
|
||||||
|
|
||||||
|
```python
|
||||||
|
UpsertUserAll(
|
||||||
|
user_id, login.loginId,
|
||||||
|
login_date_time=login.loginDateTime,
|
||||||
|
auth_date_time=login.dateTime,
|
||||||
|
token=token,
|
||||||
|
extra_data={
|
||||||
|
"userKaleidxScopeList": [{
|
||||||
|
"gateId": 1, "isGateFound": True, "isKeyFound": True, "isClear": False,
|
||||||
|
"totalRestLife": 0, "totalAchievement": 0, "totalDeluxscore": 0,
|
||||||
|
"bestAchievement": 0, "bestDeluxscore": 0,
|
||||||
|
"bestAchievementDate": "", "bestDeluxscoreDate": "",
|
||||||
|
"playCount": 0, "clearDate": "", "lastPlayDate": "", "isInfoWatched": False,
|
||||||
|
}],
|
||||||
|
"isNewKaleidxScopeList": "1",
|
||||||
|
},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`extra_data` 会自动匹配节点名、按形态强制转换、覆盖快照演算结果:
|
||||||
|
|
||||||
|
| 输入 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 键不在 36 节点里 | 抛 `PatchError`,报错信息里列出全部可用节点名 |
|
||||||
|
| 值形态不对(如对象节点传数组) | 抛 `PatchError` |
|
||||||
|
| 给了数组但没给对应位串 | 按数组长度补 `"0"`,并在日志里提示「新增条目请显式传 "1"」 |
|
||||||
|
| 也接受 JSON 字符串 / JSON 文件路径 | 内部用 `load_extra_data` 规整 |
|
||||||
|
|
||||||
|
> 位串 `"0"` = 存量已有(更新),`"1"` = 全新(新增)。
|
||||||
|
> 传新增条目时**一定要显式给 `"1"`**,否则服务端会当成更新。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、歌曲数据在哪改
|
||||||
|
|
||||||
|
全部在 `config.py` 的 `PLAY_*` 系列(默认值取自真机 `source/2026-10-05.log:123`):
|
||||||
|
|
||||||
|
| 配置项 | 含义 | 真机默认 |
|
||||||
|
|---|---|---|
|
||||||
|
| `PLAY_MUSIC_ID` / `PLAY_MUSIC_LEVEL` | 曲目与难度 | 143 / 4 |
|
||||||
|
| `PLAY_ACHIEVEMENT` | 达成率 ×10000 | 0 |
|
||||||
|
| `PLAY_JUDGE_COUNTS` | 判定明细 | 全 Miss |
|
||||||
|
| `PLAY_TOTAL_COMBO` | 谱面 note 总数 | 655 |
|
||||||
|
| `PLAY_CATEGORY_INDEX` | 曲种索引(== userExtend.categoryIndex) | 2022 |
|
||||||
|
| `PLAY_CHARACTERS` | 游玩时的角色快照(**不加经验**)★ 通常不用管,见 10.2 | 5 只 |
|
||||||
|
| `PLAY_MAP_DISTANCE_GAIN` | 地图进度增量 | **0**(不推进地图) |
|
||||||
|
| `PLAY_CHARACTER_EXP_GAIN` | 组队角色等级增量 | **0**(不给角色加经验) |
|
||||||
|
| `PLAY_LOCAL_PLAY_COUNT` | 机台本地计数器 | 8 |
|
||||||
|
| `PLAY_SPECIAL_NUM` | playSpecial 里的 CalcSpecialNum | 1024 |
|
||||||
|
|
||||||
|
**换歌 / 换成绩只改这里,不用动任何 api 脚本。**
|
||||||
|
|
||||||
|
### 10.1 ★ 两个默认 0 的含义
|
||||||
|
|
||||||
|
`PLAY_MAP_DISTANCE_GAIN` 与 `PLAY_CHARACTER_EXP_GAIN` **默认都是 0**,
|
||||||
|
即「本局不推进地图、不给角色加经验」,对应节点回传空数组:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"userMapList": [], "isNewMapList": ""
|
||||||
|
"userCharacterList": [], "isNewCharacterList": ""
|
||||||
|
```
|
||||||
|
|
||||||
|
这是**预期行为,不是错误**。`validate_before_send()` 也只在
|
||||||
|
「你把增量设成非 0、却没产出条目」时才告警。
|
||||||
|
|
||||||
|
想复刻真机的「地图 +11000 / 组队 5 只各 +1 级」,把这两个值改回
|
||||||
|
`11000` / `1` 即可(真机样本:mapId=550003 的 distance 1424000→1435000)。
|
||||||
|
|
||||||
|
### 10.2 ★ `PLAY_CHARACTERS` 通常不用管
|
||||||
|
|
||||||
|
`UpsertUserAll` 默认会调 `derive_play_characters(snapshot)`,
|
||||||
|
直接从服务器刚下发的 `userData.charaSlot` + `userCharacterList`
|
||||||
|
推出**游玩前**的 5 只角色,并覆盖这份配置。
|
||||||
|
只有拿不到快照(纯离线组包)时才会回退到这里。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、接口速查
|
||||||
|
|
||||||
|
| 函数 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `UpsertUserAll(...)` | ★ 主函数:**一个函数跑完整条结算链路**(抓快照 → 组包 → 前置接口 → 自检 → 冷却 → 发包 → 落库回查) |
|
||||||
|
| `fetch_upsert_snapshot(user_id, ...)` | 一键抓结算基线快照(`UpsertUserAll` 内部会按需自动调) |
|
||||||
|
| `build_play_from_config()` | 按 config 构造本局数据 |
|
||||||
|
| `derive_play_characters(snapshot, play)` | 从快照推游玩前 5 只角色 |
|
||||||
|
| `load_extra_data(extra)` | 把 dict / JSON 字符串 / 文件路径 规整成 dict |
|
||||||
|
| `merge_extra_data(user_all, extra)` | 把外部 JSON 合并进 36 节点(自动匹配 + 补位串 + 校验) |
|
||||||
|
| `verify_upsert_landing(user_id, snapshot, play)` | 发包后重新拉数据,确认真的落库 |
|
||||||
|
| `build_user_all(snapshot, play, patch, extra_data, ...)` | 组装 36 节点 |
|
||||||
|
| `build_user_playlog(playlog_id, login_date, ...)` | 构造 110 字段成绩明细 |
|
||||||
|
| `build_user_game_playlog(...)` | 构造 userGamePlaylogList 一条 |
|
||||||
|
| `validate_user_playlog(playlog)` | 成绩自检,返回问题列表 |
|
||||||
|
| `validate_before_send(req)` | 发包前自检,返回问题列表 |
|
||||||
|
| `build_delta(local, server, keys)` | 通用差分 |
|
||||||
|
| `build_favorite_delta(local, server)` | 收藏夹专用差分 |
|
||||||
|
| `sync_bits_to_arrays(user_all)` | 位串按数组长度补齐 |
|
||||||
|
| `coerce_node_value(node, value)` | 单节点类型强制(传错即抛) |
|
||||||
|
| `empty_user_all()` | 全空骨架 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、必知的坑
|
||||||
|
|
||||||
|
**完整清单(28 条,含现象/根因/源码依据/正确做法/自检项)见
|
||||||
|
[`UpsertUserAll踩坑清单.md`](UpsertUserAll踩坑清单.md)。**
|
||||||
|
|
||||||
|
先记住最要命的五条:
|
||||||
|
|
||||||
|
1. **服务端返回 `returnCode=1` 不代表数据被应用** —— 唯一验收标准是发包后回查数值有没有变
|
||||||
|
2. **`userData` 是整表覆盖语义** —— 原样回传旧值 = 什么都没发生(这是「回码成功但没落库」的头号成因)
|
||||||
|
3. **差分节点必须反映本局变化** —— `userMusicDetailList`/`userMapList`/`userCharacterList` 全空 = 服务端认为本局无变化
|
||||||
|
4. **`playlogId` / `loginDateTime` 必须逐字节等于 `UserLoginApi` 的返回值**
|
||||||
|
5. **`userWeeklyData` / `user2pPlaylog` 是对象不是数组**;**`userChargeList` 是整表回传不是差分**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十三、验证方式
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python selftest.py
|
||||||
|
```
|
||||||
|
|
||||||
|
覆盖 **196 项**零网络检查,含:URL 混淆(对真机 md5 值)、加解密往返、
|
||||||
|
36 节点结构与形态、110 字段完整性、差分算法、收藏夹三值位串、日期语义、
|
||||||
|
副作用闸门、位串自动补齐、快照归一化、配置常量、
|
||||||
|
`derive_play_characters` 角色推导、`extra_data` 四种输入形态与自动补位串、
|
||||||
|
number 头 / Cookie 复用等。
|
||||||
+302
@@ -0,0 +1,302 @@
|
|||||||
|
# 主脚本编写指南
|
||||||
|
|
||||||
|
> 面向「想用这个工程写自己业务流程」的人。
|
||||||
|
> 目标:**你只写业务,日志和协议细节都交给 `api/`**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、三条铁律
|
||||||
|
|
||||||
|
### 1. 不要为每个 api 调用写日志
|
||||||
|
|
||||||
|
`api/` 里每个函数在导入时都被自动套了日志外壳(`log_setup.autowrap`),
|
||||||
|
调用时会自动打印:
|
||||||
|
|
||||||
|
```
|
||||||
|
=== UserLogin 开始运行 ===
|
||||||
|
[接口说明] 用户正式登录(开启本次登录会话)。
|
||||||
|
[位置参数] 12345678, 'TpzUKJM6x1d2iKUP5yLI'
|
||||||
|
[关键字参数] region_id=1, place_id=1000
|
||||||
|
[运行结果] 成功 UserLoginResponseVO(8 字段)(耗时 77 ms)
|
||||||
|
=== UserLogin 运行结束 ===
|
||||||
|
```
|
||||||
|
|
||||||
|
所以你**不需要**在调用前后写 `log_section` / `log_info`。
|
||||||
|
主脚本里唯一必需的日志语句只有开头结尾那两句:
|
||||||
|
|
||||||
|
```python
|
||||||
|
setup_logging("我的脚本") # 开头
|
||||||
|
...
|
||||||
|
teardown_logging() # 结尾(写在 finally 里)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 一定要保证登出
|
||||||
|
|
||||||
|
账号若停留在 `isLogin=True`,下次登录会返回 `returnCode=100`。
|
||||||
|
**用 `try/finally` 兜底**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||||
|
try:
|
||||||
|
...
|
||||||
|
login = UserLogin(user_id, token, ...)
|
||||||
|
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||||
|
...
|
||||||
|
UserLogout(...)
|
||||||
|
session["logged_in"] = False
|
||||||
|
finally:
|
||||||
|
if session["logged_in"]:
|
||||||
|
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||||
|
logout_type=LogoutType.TestIn)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 有副作用的接口要显式开闸门
|
||||||
|
|
||||||
|
`UpsertUserAll` / `UpsertUserChargelog` 这类写接口默认**不发包**,
|
||||||
|
必须显式传 `confirm=True`(或 `dry_run=True` 只看报文):
|
||||||
|
|
||||||
|
```python
|
||||||
|
UpsertUserAll(..., dry_run=True) # 只构造,不发
|
||||||
|
UpsertUserAll(..., dry_run=False, confirm=True) # 真发
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠ 只写 `dry_run=False` **不够** —— `confirm=None` 时会去读
|
||||||
|
> `config.UPSERT_CONFIRM`(默认 `False`),照样被拦。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、最小骨架
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import sys
|
||||||
|
from api import (setup_logging, teardown_logging,
|
||||||
|
ParseQRCode, UserPreview, UserLogin, UserLogout, LogoutType)
|
||||||
|
from config import REGION_ID, PLACE_ID
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
setup_logging("我的脚本")
|
||||||
|
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||||
|
try:
|
||||||
|
user_id, token = ParseQRCode(sys.argv[1])
|
||||||
|
session.update(user_id=user_id, token=token)
|
||||||
|
|
||||||
|
UserPreview(user_id, token) # 顶号保护
|
||||||
|
login = UserLogin(user_id, token,
|
||||||
|
region_id=REGION_ID, place_id=PLACE_ID)
|
||||||
|
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||||
|
|
||||||
|
# ---- 你的业务写在这里 ----
|
||||||
|
|
||||||
|
UserLogout(user_id, token, login.loginDateTime,
|
||||||
|
logout_type=LogoutType.TestIn,
|
||||||
|
region_id=REGION_ID, place_id=PLACE_ID)
|
||||||
|
session["logged_in"] = False
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
if session["logged_in"]:
|
||||||
|
try:
|
||||||
|
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||||
|
logout_type=LogoutType.TestIn,
|
||||||
|
region_id=REGION_ID, place_id=PLACE_ID)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
teardown_logging()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、日志
|
||||||
|
|
||||||
|
### 3.1 两种模式(改 config.py 一行)
|
||||||
|
|
||||||
|
| `LOG_LEVEL` | 输出 |
|
||||||
|
|---|---|
|
||||||
|
| `"info"`(默认) | 只展示 api 脚本的**运行情况和结果** |
|
||||||
|
| `"debug"` | 额外展示**向 api 发送的数据**和 **api 返回的数据**(含完整报文) |
|
||||||
|
|
||||||
|
临时切换(不改 config):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python upsert.py <二维码> --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
> ★ DEBUG 会完整打印收发报文,一次结算流程能到 **十几 MB**。
|
||||||
|
> 只想看流程就别开;想瘦身调 `config.LOG_VALUE_MAX`。
|
||||||
|
|
||||||
|
### 3.2 两个日志文件(同时写)
|
||||||
|
|
||||||
|
| 文件 | 模式 | 内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| `logs/latest.log` | 覆盖(`w`) | 只含**当次**主脚本运行 |
|
||||||
|
| `logs/YYYY-MM-DD.log` | 追加(`a`) | 当日**全部**日志 |
|
||||||
|
|
||||||
|
### 3.3 想记自己的话(可选)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api import log_info, log_warning, log_error, log_section, log_field
|
||||||
|
|
||||||
|
log_section("阶段标题") # 带分隔线
|
||||||
|
log_info("普通信息")
|
||||||
|
log_warning("要留意的情况")
|
||||||
|
log_error("出错了")
|
||||||
|
log_field("预览数据", preview) # INFO 一行概要;DEBUG 展开完整 JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 嵌套调用也会自动记
|
||||||
|
|
||||||
|
`UpsertUserAll` 内部会调 `fetch_upsert_snapshot` / `UserNewItemList` / `UserData`,
|
||||||
|
这些**内部调用同样打印自己的开始/结束行**,日志层次完整嵌套:
|
||||||
|
|
||||||
|
```
|
||||||
|
=== UpsertUserAll 开始运行 ===
|
||||||
|
=== fetch_upsert_snapshot 开始运行 ===
|
||||||
|
...
|
||||||
|
=== fetch_upsert_snapshot 运行结束 ===
|
||||||
|
=== UserNewItemList 开始运行 ===
|
||||||
|
...
|
||||||
|
=== UserNewItemList 运行结束 ===
|
||||||
|
...
|
||||||
|
=== UpsertUserAll 运行结束 ===
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、参数约定
|
||||||
|
|
||||||
|
| 类型 | 位置 | 例子 |
|
||||||
|
|---|---|---|
|
||||||
|
| **业务数据**(必需) | 位置参数 | `user_id` / `token` / `二维码` / `playlog_id` / `charge_id` |
|
||||||
|
| **配置项**(可省) | 关键字参数 | `client_id` / `region_id` / `place_id` / `timeout_ms` / `client` |
|
||||||
|
|
||||||
|
**`client` 参数**:所有 api 都接受一个可选的 `MaimaiNetClient`。
|
||||||
|
不传时走全局单例 `get_default_client()`,**连接和 Cookie(JSESSIONID)自动复用**。
|
||||||
|
|
||||||
|
> ### ★ 不要自己 `MaimaiNetClient()` 新建去发任何带 userId 的包
|
||||||
|
>
|
||||||
|
> 源码 `Packet.cs:187-194`:**所有非 Ping 且 `userId != 0` 的请求都会
|
||||||
|
> `Client.AddCookie(Query.UserId)`** —— **登出也不例外**。
|
||||||
|
>
|
||||||
|
> ```csharp
|
||||||
|
> if (!IsPingPacket())
|
||||||
|
> {
|
||||||
|
> Client.AddHeader("number", GameManager.PingMsec.ToString());
|
||||||
|
> if (Query.UserId != 0L) { Client.AddCookie(Query.UserId); } // ← 登出也走这里
|
||||||
|
> }
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 那个 CookieContainer 是 **`UserLoginApi` 响应里的 `Set-Cookie: JSESSIONID=...`**
|
||||||
|
> 存下来的(`Packet.cs:228-234`)。
|
||||||
|
>
|
||||||
|
> **为什么对登出尤其要命**:登出请求体里**没有 token**,只有
|
||||||
|
> `userId` / `accessCode` / `regionId` / `placeId` / `clientId` /
|
||||||
|
> `loginDateTime` / `type` / `delayLog`。
|
||||||
|
> 服务端能用来定位「哪个会话」的,除了 `userId + loginDateTime`,就剩这个 JSESSIONID。
|
||||||
|
> **丢掉它有可能登出失败 → 账号卡在 `isLogin=True`(下次登录 returnCode=100)**。
|
||||||
|
>
|
||||||
|
> 结论:**全程复用同一个 client**(不传 `client=` 即默认单例,自动满足)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、四个典型流程模板
|
||||||
|
|
||||||
|
### 5.1 只登录(探活 / 查状态)
|
||||||
|
|
||||||
|
```python
|
||||||
|
user_id, token = ParseQRCode(qr)
|
||||||
|
UserPreview(user_id, token)
|
||||||
|
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||||
|
# ... 用 UserData / UserMusicDetailAll 等取数 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 上传一局成绩(最常用)
|
||||||
|
|
||||||
|
```python
|
||||||
|
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||||
|
|
||||||
|
# ★ 一个函数跑完整条链路:抓快照 → 推角色 → 造成绩明细 → 前置接口
|
||||||
|
# → 组 36 节点 → 合并 extra_data → 自检 → 冷却 → 发包 → 落库回查
|
||||||
|
UpsertUserAll(
|
||||||
|
user_id, login.loginId,
|
||||||
|
login_date_time=login.loginDateTime,
|
||||||
|
auth_date_time=login.dateTime,
|
||||||
|
token=token, # 让内部自动抓基线快照
|
||||||
|
dry_run=False, confirm=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**换歌 / 换成绩只改 `config.py` 的 `PLAY_*`**,不用动主脚本。
|
||||||
|
|
||||||
|
**想额外塞节点**用 `extra_data`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
UpsertUserAll(..., extra_data={
|
||||||
|
"userKaleidxScopeList": [{"gateId": 1, "isGateFound": True, ...}],
|
||||||
|
"isNewKaleidxScopeList": "1",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 买一张功能票
|
||||||
|
|
||||||
|
见 `buy_ticket.py` 与 `docs/购买功能票流程.md`。核心就一句:
|
||||||
|
|
||||||
|
```python
|
||||||
|
UpsertUserChargelog(user_id, charge_id=2,
|
||||||
|
login_date_time=login.loginDateTime,
|
||||||
|
dry_run=False, confirm=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 只取数(不发任何写接口)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api import UserData, UserMusicDetailAll, UserRating
|
||||||
|
|
||||||
|
ud = UserData(user_id)
|
||||||
|
print(ud["userData"]["playCount"])
|
||||||
|
|
||||||
|
for m in (UserMusicDetailAll(user_id).get("userMusicList") or []):
|
||||||
|
if m.get("musicId") == 143:
|
||||||
|
print(m)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 取数类 api 不需要 `confirm`,也没有副作用,可以随便调。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、常见错误
|
||||||
|
|
||||||
|
| 现象 | 原因 | 解决 |
|
||||||
|
|---|---|---|
|
||||||
|
| 登录 `returnCode=100` | 账号卡在 `isLogin=True` | 上次没登出;等超时或用 `finally` 兜底 |
|
||||||
|
| `UpsertUserAll` 抛 `ConfirmRequiredError` | 只传了 `dry_run=False`,没传 `confirm=True` | 补 `confirm=True` |
|
||||||
|
| 扫码 `errorID=2` | 二维码**过期**了。它有**时效性**(**有效期 10 分钟**),**不是按次数计量的** | 重新生成一个 |
|
||||||
|
| `returnCode=1` 但数据没变 | 差分节点全空(见踩坑清单坑 2) | 检查 `PLAY_*` 增量配置 |
|
||||||
|
| 日志文件特别大 | 开了 DEBUG | 关掉 `--debug` 或调小 `LOG_VALUE_MAX` |
|
||||||
|
| `RuntimeError: 接口 xxx 返回 0 字节` | `Accept-Encoding` 被改过 | 不要动 `maimai_net` 的该头 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、自己写一个新 api
|
||||||
|
|
||||||
|
1. 在 `api/` 下新建脚本,定义一个能传参的函数
|
||||||
|
2. 函数首行写 docstring(会变成日志的 `[接口说明]`)
|
||||||
|
3. 用 `from .maimai_net import request_dict` 发包
|
||||||
|
4. 用 `from .log_setup import log_info` 记关键结果
|
||||||
|
5. **在 `api/__init__.py` 里导入并加进 `_API_FUNCS`** ← 漏了这步就没有开始/结束提示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、相关文档
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `docs/项目总览.md` | 工程结构与 api 清单 |
|
||||||
|
| `docs/日志与主脚本指南.md` | 日志模块完整参考 |
|
||||||
|
| `docs/上传成绩开发文档.md` | UpsertUserAll 的 36 节点 / 110 字段 / 差分语义 |
|
||||||
|
| `docs/UpsertUserAll踩坑清单.md` | 28 条坑 + 发包前 Checklist |
|
||||||
|
| `docs/购买功能票流程.md` | 登录 → 买票 → 登出 |
|
||||||
|
| `upsert.py` | 主脚本范例(极简版) |
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
# 功能票使用说明(购买 / 消耗)
|
||||||
|
|
||||||
|
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里功能票部分的**详细版**。
|
||||||
|
> 购买接口的完整排查过程见 [购买功能票流程.md](购买功能票流程.md) 与
|
||||||
|
> [购买功能票_存档与交接.md](购买功能票_存档与交接.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、票的种类
|
||||||
|
|
||||||
|
机台**可购买**的是付费票(`ticketKind=1`,`api.PAID_TICKET_IDS = (2,3,4,5,6)`):
|
||||||
|
|
||||||
|
| chargeId | 票 | 上报的 `price`(= 票的 `creditNum`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 2 | 2倍チケット | 1 |
|
||||||
|
| 3 | 3倍チケット | 2 |
|
||||||
|
| 5 | 5倍チケット | 4 |
|
||||||
|
|
||||||
|
> 完整表(含 4/6 倍与活动票)见 `api/upsert_user_chargelog.py` 的 `TICKET_CREDIT_NUM`。
|
||||||
|
>
|
||||||
|
> ⚠️ **不同店铺上架的票不一样。** 买之前先 `--list` 看一眼 ——
|
||||||
|
> 服务端对未上架的票会直接 `returnCode=0`(实测过)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、购买:`buy_ticket.py`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python buy_ticket.py "<二维码>" --list # 只列当前票券和可买的票
|
||||||
|
python buy_ticket.py "<二维码>" --charge-id 2 # 预检:只看报文
|
||||||
|
python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--charge-id N` | **5** | 买哪张票 |
|
||||||
|
| `--list` | — | 只列票券与可购买清单,不做任何写操作 |
|
||||||
|
| `--send` | 关 | ★ 真实发包 |
|
||||||
|
| `--debug` | 关 | 日志里带完整收发报文 |
|
||||||
|
| `--skip-entry` | 关 | 跳过登录后的「入场全量下载」 |
|
||||||
|
| `--wait-before N` | **30** | 买票前等待秒数(真机 login→买票是 26 秒) |
|
||||||
|
|
||||||
|
### 三道安全检查
|
||||||
|
|
||||||
|
1. **买之前**先查 `GetGameChargeApi` —— 目标票**不在当前可购买清单里就直接退出**,
|
||||||
|
连登录都不做(不浪费二维码)。
|
||||||
|
依据 `source/Assembly-CSharp/Manager/TicketManager.cs:137-150`。
|
||||||
|
2. **跑前查一次** `GetUserChargeApi`:**账号已经持有功能票时直接拒绝购买**
|
||||||
|
(重复购买有封号风险)。想清掉手里的票用 `use_ticket.py`。
|
||||||
|
3. **跑后再查一次**,确认票券状态符合预期。
|
||||||
|
|
||||||
|
### 报文形态
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": <uid>,
|
||||||
|
"userChargelog": {
|
||||||
|
"chargeId": 2, "price": 1,
|
||||||
|
"purchaseDate": "2026-10-06 22:23:00.0",
|
||||||
|
"playCount": 1268, "playerRating": 15534,
|
||||||
|
"placeId": 1007, "regionId": 8, "clientId": "A63E01C3047"
|
||||||
|
},
|
||||||
|
"userCharge": {
|
||||||
|
"chargeId": 2, "stock": 1,
|
||||||
|
"purchaseDate": "2026-10-06 22:23:00.0",
|
||||||
|
"validDate": "2027-01-04 04:00:00"
|
||||||
|
},
|
||||||
|
"loginDateTime": 1791325354
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 依据 |
|
||||||
|
|---|---|
|
||||||
|
| `price` | = `ticket.creditNum`(**不是** `GetGameChargeApi` 里的 money price)。`VOExtensions.cs:410` |
|
||||||
|
| `stock` | **购买后**的库存(原值 + 1) |
|
||||||
|
| `purchaseDate` | `TimeManager.DateTimeFormat` = `yyyy-MM-dd HH:mm:ss.f`,**带一位小数** |
|
||||||
|
| `validDate` | `TicketManager.cs:562-565`:`Now.AddHours(-4).AddDays(90).ToString("yyyy-MM-dd 04:00:00")`,**不带小数** |
|
||||||
|
|
||||||
|
> ⚠️ 这两个日期格式不一样 —— `purchaseDate` 带 `.f` 而 `validDate` 不带。
|
||||||
|
> 少写 `.0` 服务端直接 `returnCode=0`(踩过)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、消耗:`use_ticket.py`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python use_ticket.py "<二维码>" --list # 只列当前持有的票
|
||||||
|
python use_ticket.py "<二维码>" --ticket-id 2 # 预检:只看报文
|
||||||
|
python use_ticket.py "<二维码>" --ticket-id 2 --send # ★ 真实消耗
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--ticket-id N` | 第一张持有的 | 消耗哪一张 |
|
||||||
|
| `--list` | — | 只列票券,不做任何写操作 |
|
||||||
|
| `--send` | 关 | ★ 真实发包 |
|
||||||
|
| `--music-id N` / `--level N` | `config.PLAY_MUSIC_ID` / `PLAY_MUSIC_LEVEL` | 「这一局」打的歌 |
|
||||||
|
| `--skip-entry` | 关 | 跳过入场全量下载 |
|
||||||
|
| `--debug` | 关 | 日志里带完整收发报文 |
|
||||||
|
|
||||||
|
### 原理
|
||||||
|
|
||||||
|
`GetUserChargeApi` 与 `UpsertUserAllApi` 里的 `userChargeList`
|
||||||
|
**格式完全相同**(`UserCharge.cs`:`chargeId` / `stock` / `purchaseDate` / `validDate`),
|
||||||
|
所以只要把该节点里目标票的 `stock` 减 1,再让 playlog 带上
|
||||||
|
`useTicketId` / `playCredit`,服务端就认为这张票被这一局用掉了。
|
||||||
|
|
||||||
|
**真机实证**(2026-10-06 22:23 那局正好用了 2 倍券):
|
||||||
|
|
||||||
|
```
|
||||||
|
userChargeList: chargeId=2 stock 1 -> 0 ← 票被消耗
|
||||||
|
userGamePlaylogList[0].useTicketId = 2 ← 用了哪张票
|
||||||
|
userGamePlaylogList[0].playCredit = 2
|
||||||
|
userPlaylogList[0].playCredit = 2
|
||||||
|
userData.lastPlayCredit = 2
|
||||||
|
(报文里**没有** isNewChargeList 这个节点)
|
||||||
|
```
|
||||||
|
|
||||||
|
`playCredit` 的算法(`VOExtensions.cs:838-845`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
int num = Credit.GameCostEnoughPlay(); // 投币数,普通局 = 1
|
||||||
|
int useTicketId = TicketManager.GetUseTicketId(playerId);
|
||||||
|
int ticketCredit= TicketManager.GetTicketCredit(useTicketId); // = 票的 creditNum
|
||||||
|
int playCredit = num + ticketCredit;
|
||||||
|
```
|
||||||
|
|
||||||
|
2 倍券的 `creditNum = 1` → `playCredit = 1 + 1 = 2`,与抓包吻合。
|
||||||
|
|
||||||
|
### 实测记录(2026-10-07 00:24)
|
||||||
|
|
||||||
|
```
|
||||||
|
[消耗前] chargeId=2 2倍チケット stock=1 ← 持有中
|
||||||
|
选定要消耗的票:chargeId=2 creditNum=1 -> playCredit=1+1=2
|
||||||
|
userChargeList 里 chargeId=2 的 stock:1 -> 0
|
||||||
|
★ 上传成绩成功:returnCode=1
|
||||||
|
落库回查:
|
||||||
|
playCount 1269 -> 1269 (PLAY_USER_PLAY_COUNT_GAIN=0,不再 +1)
|
||||||
|
currentPlayCount 197 -> 197
|
||||||
|
lastPlayDate 2026-10-06 22:23:56 -> 2026-10-07 00:24:07 ← 落库成功
|
||||||
|
musicId=143 level=4 playCount -> 11 (曲目次数不变)
|
||||||
|
★ 票券已消耗:chargeId=2 不再持有 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、典型用法:票挂在手里,想再买一张
|
||||||
|
|
||||||
|
`buy_ticket.py` 检测到**已持票**会拒绝购买。想再买一张:
|
||||||
|
|
||||||
|
1. 用 `use_ticket.py --send` 把手里那张消耗掉,或
|
||||||
|
2. 直接在机台上打一局并选用它
|
||||||
|
|
||||||
|
然后再跑 `buy_ticket.py`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、购买接口曾经的坑(摘要)
|
||||||
|
|
||||||
|
`UpsertUserChargelogApi` 曾经一直返回 `returnCode=0`,报文与真机**逐字节一致**也照样失败。
|
||||||
|
|
||||||
|
**根因:请求头多发了 `Accept` 与 `Connection`。**
|
||||||
|
|
||||||
|
真机(Unity Mono 的 `HttpWebRequest`)实际只发这几个头:
|
||||||
|
|
||||||
|
```
|
||||||
|
number / Content-Type / User-Agent / charset / Mai-Encoding / Content-Encoding / Cookie
|
||||||
|
```
|
||||||
|
|
||||||
|
删掉这两个头之后一次通过(`stock` 0 → 1,落库确认)。
|
||||||
|
|
||||||
|
> 完整排查过程(12 项已排除假设、账号对照实验、真机抓包对比)见
|
||||||
|
> [购买功能票流程.md](购买功能票流程.md) 与 [购买功能票_存档与交接.md](购买功能票_存档与交接.md)。
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
# 实跑验证记录(真实发包 + 落库确认)
|
||||||
|
|
||||||
|
> 本文记录本工程**第一次真实跑通**「扫码登录 → 取数 → 上传成绩 → 落库回查 → 登出」的完整证据。
|
||||||
|
> 执行方式:`python upsert.py <二维码> --send`(等价于下面的驱动方式)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结论
|
||||||
|
|
||||||
|
| 环节 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| 扫码登录 | ✅ user_id=12345678,token 获取成功 |
|
||||||
|
| 用户预览 | ✅ errorId=0、banState=0、isLogin=False |
|
||||||
|
| 正式登录 | ✅ returnCode=1,loginId=7693236821794227665 |
|
||||||
|
| 抓基线快照 | ✅ 19 个节点全部成功,失败 0 个 |
|
||||||
|
| 组包 + 发包前自检 | ✅ 0 问题 |
|
||||||
|
| 冷却 | ✅ 等待 60.0 秒(模拟真机节奏) |
|
||||||
|
| **上传成绩** | ✅ **returnCode=1** |
|
||||||
|
| **落库回查** | ✅ **userData.playCount 1260 → 1261** |
|
||||||
|
| 登出 | ✅ returnCode=1 |
|
||||||
|
|
||||||
|
**这是关键突破**:之前的失败模式是「returnCode=1 但没落库」。
|
||||||
|
本次是**回码成功 + 重新拉服务器数据确认数值真的变了**,两者同时成立。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、时间线(2026-10-06 01:28)
|
||||||
|
|
||||||
|
```
|
||||||
|
01:28:01 扫码登录成功 user_id=12345678
|
||||||
|
01:28:01 用户登录成功 loginId=7693236821794227665
|
||||||
|
loginDateTime=1791250081 dateTime=1791221281
|
||||||
|
01:28:03 结算快照抓取完成 成功 19 个节点,失败 0 个
|
||||||
|
01:28:04 角色快照取自服务器 395:3803 / 101:4458 / 400402:137 / 400401:137 / 200401:137
|
||||||
|
01:28:04 发包前自检通过
|
||||||
|
01:28:04 上传成绩冷却 等待 60.0 秒后再发送(模拟真机节奏)
|
||||||
|
01:29:04 冷却结束 共等待 60.0 秒
|
||||||
|
01:29:04 上传成功 returnCode=1
|
||||||
|
01:29:04 落库回查:
|
||||||
|
playCount 1260 -> 1261
|
||||||
|
currentPlayCount 188 -> 189
|
||||||
|
lastPlayDate 2026-10-05 22:32:21 -> 2026-10-06 01:28:04
|
||||||
|
musicId=143 level=4 playCount -> 11
|
||||||
|
01:29:05 【落库成功】userData.playCount 已 +1
|
||||||
|
01:29:05 用户登出成功 returnCode=1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、落库证据(上传后重新拉服务器数据)
|
||||||
|
|
||||||
|
上传成功后**不登出、立刻**调 `GetUserDataApi` + `GetUserMusicApi` 回读,服务器返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userData": {
|
||||||
|
"playCount": 1261,
|
||||||
|
"currentPlayCount": 189,
|
||||||
|
"lastPlayDate": "2026-10-06 01:28:04",
|
||||||
|
"lastLoginDate": "2026-10-06 01:28:01",
|
||||||
|
"eventWatchedDate": "2026-10-06 01:28:01",
|
||||||
|
"lastPlaceId": 0,
|
||||||
|
"point": 4460,
|
||||||
|
"charaSlot": [395, 101, 400402, 400401, 200401]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
musicId=143 level=4 -> playCount = 11
|
||||||
|
```
|
||||||
|
|
||||||
|
对照**上传前**的基线:`playCount=1260`、`currentPlayCount=188`、`(143,4).playCount=10`。
|
||||||
|
三项全部 +1,**证明服务端真的把这份报文写进去了**。
|
||||||
|
|
||||||
|
同时确认:
|
||||||
|
- `point` 保持 4460 不变 —— 与「`userGetPointList` / `userLoginBonusList` 留空」的预期一致
|
||||||
|
- `lastLoginDate` = 登录时刻(01:28:01),`lastPlayDate` = 上报时刻(01:28:04),两者分离正确
|
||||||
|
- `charaSlot` 未被破坏
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、这次跑通依赖的关键实现点
|
||||||
|
|
||||||
|
1. **`number` 请求头**:先跑 `Ping()` 取得往返毫秒,之后所有请求带上(本次 47 ms)
|
||||||
|
2. **Cookie 复用**:全程同一个 `MaimaiNetClient`,`UserLoginApi` 的 `Set-Cookie: JSESSIONID` 自动带入后续请求
|
||||||
|
3. **userData 整表覆盖**:先抓 19 节点快照作基线,再按本局演算 +1 后整表回传
|
||||||
|
4. **差分节点只回传变化项**:`userMusicDetailList` 只回传 (143,4) 那条、`userMapList` 只回传 550003、
|
||||||
|
`userCharacterList` 只回传 5 只组队角色
|
||||||
|
5. **11 条位串与数组长度严格对齐**:`isNewFavoriteList="22212"` 等全部复刻真机
|
||||||
|
6. **`playSpecial` 用 `CalcSpecialNum=1024`**:与真机位模式一致
|
||||||
|
7. **`characterLevelN` 用游玩前等级**:playlog 3803/4458/137,userCharacterList 才是 3804/4459/138
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、复现方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 预检(不发包,零副作用)
|
||||||
|
python upsert.py "<二维码>" --debug
|
||||||
|
|
||||||
|
# 2) 真实上传(★ 会把成绩写进服务器,不可撤销)
|
||||||
|
python upsert.py "<二维码>" --send
|
||||||
|
|
||||||
|
# 想跳过 60 秒冷却
|
||||||
|
python upsert.py "<二维码>" --send --cooldown 0
|
||||||
|
|
||||||
|
# 想跳过上传后的落库回查
|
||||||
|
python upsert.py "<二维码>" --send --no-verify
|
||||||
|
```
|
||||||
|
|
||||||
|
`--send` 会自动做落库回查:重新拉 `GetUserDataApi` + `GetUserMusicApi`,
|
||||||
|
对比 `playCount` 有没有 +1,并打印 `【落库成功】` 或 `【疑似未落库】`。
|
||||||
|
|
||||||
|
> ★ 建议先用 `--debug` 跑一次预检:DEBUG 日志会完整打印请求头、请求体、响应体,
|
||||||
|
> 便于逐字段核对。代价是日志文件会很大(本次 DEBUG 日志约 200 万行)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、账号状态变化(本次真实写入的内容)
|
||||||
|
|
||||||
|
| 项 | 上传前 | 上传后 |
|
||||||
|
|---|---|---|
|
||||||
|
| `userData.playCount` | 1260 | **1261** |
|
||||||
|
| `userData.currentPlayCount` | 188 | **189** |
|
||||||
|
| `userData.lastPlayDate` | 2026-10-05 22:32:21 | **2026-10-06 01:28:04** |
|
||||||
|
| `userMusicDetailList` (143, Re:Master) playCount | 10 | **11** |
|
||||||
|
| `userMapList` 550003 distance | 1457000 | **1468000**(+11000) |
|
||||||
|
| `userCharacterList` 5 只组队角色 | 3803/4458/137/137/137 | **3804/4459/138/138/138**(+1) |
|
||||||
|
| `userData.point` | 4460 | 4460(不变) |
|
||||||
|
|
||||||
|
写入的 `userPlaylogList[0]` 是 `musicId=143`(Re:Master)全 Miss 的失败局,
|
||||||
|
判定数 441/19/192/0/3,与 `config.PLAY_*` 一致。
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# 常见坑与注意事项
|
||||||
|
|
||||||
|
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里被精简掉的
|
||||||
|
> 「常见坑与注意事项」章节的**详细版**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、二维码
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 有效期 | **10 分钟**,过期后 aimedb 返回 `errorID=2` |
|
||||||
|
| 计量方式 | ★ **不是按次数计量的**,只是有时效;重新生成即可 |
|
||||||
|
| 来源 | 机台的「扫码登录」界面 |
|
||||||
|
| 每个脚本各要一次 | `reincarnate.py` 需要目标号的二维码;源号可以用 `--src-json` 免掉 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、账号状态
|
||||||
|
|
||||||
|
- **一定要登出。** 账号卡在 `isLogin=True` 时,下次登录会 `returnCode=100`。
|
||||||
|
本工程所有主脚本都在 `finally` 里兜底登出,异常也不会漏。
|
||||||
|
- 想确认状态可以看 `GetUserPreviewApi` 的 `isLogin` 字段。
|
||||||
|
- 登出类型:真机用 **type=1(Logout)**,见 [账号转生说明.md](账号转生说明.md) 第五节。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、功能票
|
||||||
|
|
||||||
|
- 账号**已经持有**功能票时,`buy_ticket.py` **会拒绝购买**(重复购买有封号风险)。
|
||||||
|
- 想再买一张,先用 `use_ticket.py` 把手里那张用掉,或直接在机台上打一局选用它。
|
||||||
|
- 不同店铺上架的票不一样,买之前先 `--list`。
|
||||||
|
- 详见 [功能票使用说明.md](功能票使用说明.md)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、副作用
|
||||||
|
|
||||||
|
| 命令 | 后果 |
|
||||||
|
|---|---|
|
||||||
|
| 不带 `--send` | 只构造报文,**不发包**(安全,随便跑) |
|
||||||
|
| 带 `--send` | **真实写入服务器,不可撤销** |
|
||||||
|
|
||||||
|
涉及真实写入的脚本:`upsert.py` / `buy_ticket.py` / `use_ticket.py` / `reincarnate.py`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、日志
|
||||||
|
|
||||||
|
```
|
||||||
|
logs/latest.log 只含**当次**运行(下次覆盖)
|
||||||
|
logs/YYYY-MM-DD.log 当日全部日志(追加)
|
||||||
|
```
|
||||||
|
|
||||||
|
改 `config.py` 的 `LOG_LEVEL`:
|
||||||
|
|
||||||
|
| 值 | 效果 |
|
||||||
|
|---|---|
|
||||||
|
| `"info"` | 只展示 api 脚本的运行情况和结果 |
|
||||||
|
| `"debug"` | 额外展示向 api 发送的数据与 api 返回的数据 |
|
||||||
|
|
||||||
|
> 主脚本也可以加 `--debug` 临时开启,不用改 `config.py`。
|
||||||
|
> DEBUG 日志很大(一局能到十几 MB),`config.LOG_VALUE_MAX` 可以限制单值长度。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、网络
|
||||||
|
|
||||||
|
- 服务器偶发超时 / 连接重置,**重试即可**(买票那次就是这么过的)。
|
||||||
|
- 所有请求默认 `timeout` 10 秒,登录与结算用 60 秒。
|
||||||
|
- `GetGameChargeApi` 偶尔会读超时,重跑一次通常就好。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、不要提交的东西
|
||||||
|
|
||||||
|
```
|
||||||
|
config.py 你的机台身份,.gitignore 已忽略
|
||||||
|
data/ 用户数据快照
|
||||||
|
download/ .app / .opt 更新包
|
||||||
|
source/ 反编译源码(版权)
|
||||||
|
logs/ 运行日志
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ `.gitignore` 的行尾注释不是注释
|
||||||
|
|
||||||
|
写:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
data/ # 每账号一份用户数据快照
|
||||||
|
```
|
||||||
|
|
||||||
|
**是错的** —— `#` 只在**行首**才是注释,整行会被当成一个含空格和 `#` 的 pattern,
|
||||||
|
**匹配不到任何东西**。结果就是规则失效。
|
||||||
|
|
||||||
|
正确写法:
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
# 每账号一份用户数据快照
|
||||||
|
data/
|
||||||
|
```
|
||||||
|
|
||||||
|
> 这条坑踩过一次:`data/12570152.json`(604 KB 用户数据)被误提交进 git。
|
||||||
|
> 修复方式是 `git rm --cached` + `commit --amend` + `reflog expire` + `gc --prune=now`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、其它
|
||||||
|
|
||||||
|
- **换歌 / 换成绩只改 `config.py` 的 `PLAY_*`**,不用动任何代码。
|
||||||
|
- `config.py` 不入库,仓库里只有 `config.py.example` 模板;
|
||||||
|
第一次用要先 `copy config.py.example config.py` 并填好「机台与店铺标识」。
|
||||||
|
- `api/` 里每个脚本 = 一个接口,函数名与文件名都带 `Get`(如果 API 名里有 Get)。
|
||||||
|
- 想离线复现某次请求,用 `--debug` 抓报文 + `selftest.py` 里的对拍用例。
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
# 日志与主脚本指南
|
||||||
|
|
||||||
|
> 模块:`api/log_setup.py` | 模式:INFO / DEBUG
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、一句话理解
|
||||||
|
|
||||||
|
**api 调用会被自动识别并记录,主脚本不用写任何日志代码。**
|
||||||
|
|
||||||
|
主脚本要写的只有两件:
|
||||||
|
|
||||||
|
```python
|
||||||
|
setup_logging("脚本名") # 开头一句
|
||||||
|
... # 业务代码,照常调 api
|
||||||
|
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、自动日志长什么样
|
||||||
|
|
||||||
|
调用了 `ParseQRCode` 与 `UserPreview`,日志自动产出:
|
||||||
|
|
||||||
|
```
|
||||||
|
==================================================================
|
||||||
|
日志会话开始 —— main
|
||||||
|
==================================================================
|
||||||
|
主脚本 : main
|
||||||
|
开始时间 : 2026-10-05 17:37:42
|
||||||
|
日志模式 : INFO(仅运行情况与结果)
|
||||||
|
Python : 3.12.6 (CPython AMD64)
|
||||||
|
工作目录 : D:\SDGBSource\杂乱的api\wahappy-v0-goal
|
||||||
|
已挂载 api : 60 个(自动跟踪,主脚本无需写日志代码)
|
||||||
|
当次日志 : ...\logs\latest.log
|
||||||
|
日期日志 : ...\logs\2026-10-05.log
|
||||||
|
==================================================================
|
||||||
|
|
||||||
|
=== ParseQRCode 开始运行 ===
|
||||||
|
[接口说明] 解析二维码并换取 userId 与 token。
|
||||||
|
[位置参数] SGWCMAID2610022219463A297AF4CAB863406AA91ED2D29FF3BE20BF1113AC03095D97BD1573EDD691B6
|
||||||
|
解析二维码登录:chip_id=TESTCLIENT1
|
||||||
|
发送登录请求:timestamp=261005173742 host=ai.sys-allnet.cn/wc_aime/api/get_data
|
||||||
|
扫码登录成功:user_id=12345678 token=t3WikM5n...(长度 20)
|
||||||
|
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
|
||||||
|
=== ParseQRCode 运行结束 ===
|
||||||
|
|
||||||
|
=== UserPreview 开始运行 ===
|
||||||
|
[接口说明] 获取用户预览信息。
|
||||||
|
[位置参数] 12345678, 'TESTTOKEN0000000000'
|
||||||
|
[关键字参数] client_id='TESTCLIENT1'
|
||||||
|
获取用户预览:user_id=12345678 client_id=TESTCLIENT1
|
||||||
|
用户预览获取完成:userName=☆Astral☆ playerRating=15000 isLogin=False isNetMember=1
|
||||||
|
[运行结果] 成功 UserPreviewResponseVO(22 字段)(耗时 388 ms)
|
||||||
|
=== UserPreview 运行结束 ===
|
||||||
|
|
||||||
|
==================================================================
|
||||||
|
本次运行调用的 api
|
||||||
|
==================================================================
|
||||||
|
1. ParseQRCode
|
||||||
|
2. UserPreview
|
||||||
|
==================================================================
|
||||||
|
日志会话结束 —— main
|
||||||
|
==================================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
自动带出来的东西:
|
||||||
|
|
||||||
|
| 自动内容 | 来源 |
|
||||||
|
|---|---|
|
||||||
|
| `===函数名 开始运行===` / `===函数名 运行结束===` | 导入时自动包装 |
|
||||||
|
| `[接口说明]` | 函数 docstring 首行 |
|
||||||
|
| `[位置参数]` / `[关键字参数]` | 本次调用传入的值 |
|
||||||
|
| `[运行结果]` | 成功摘要或异常类型,附耗时 |
|
||||||
|
| 会话结束的 **api 汇总清单** | 运行时记录调用顺序 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、两种模式
|
||||||
|
|
||||||
|
| 模式 | 你能看到什么 |
|
||||||
|
|---|---|
|
||||||
|
| **INFO**(默认) | 开始 → 接口说明 → 参数 → 结果 → 结束 |
|
||||||
|
| **DEBUG** | 上面全部 **+** 请求 URL、请求头、**请求体 JSON 全文**、状态码、耗时、响应头、**响应体 JSON 全文** |
|
||||||
|
|
||||||
|
切换方式:改 `config.py` 一行。
|
||||||
|
|
||||||
|
```python
|
||||||
|
LOG_LEVEL = "info" # 日常运行
|
||||||
|
LOG_LEVEL = "debug" # 排查协议问题
|
||||||
|
```
|
||||||
|
|
||||||
|
DEBUG 下额外多出:
|
||||||
|
|
||||||
|
```
|
||||||
|
-> 请求 GetGameSettingApi
|
||||||
|
URL : https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/83a5d5a20b062c5c2ad817460b8f7f76
|
||||||
|
请求头 : {"Content-Type":"application/json","charset":"UTF-8","Mai-Encoding":"1.55","User-Agent":"83a5...#TESTCLIENT1","Accept-Encoding":"","Content-Encoding":"deflate"}
|
||||||
|
请求体 :
|
||||||
|
{
|
||||||
|
"placeId": 1000,
|
||||||
|
"clientId": "TESTCLIENT1"
|
||||||
|
}
|
||||||
|
|
||||||
|
<- 响应 GetGameSettingApi
|
||||||
|
状态码 : 200
|
||||||
|
耗时 : 312 ms
|
||||||
|
响应头 : {"Date":"...","Content-Type":"application/json"}
|
||||||
|
响应体 :
|
||||||
|
{
|
||||||
|
"isAouAccession": false,
|
||||||
|
"gameSetting": {
|
||||||
|
"isMaintenance": false,
|
||||||
|
"requestInterval": 1200,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> DEBUG 模式下**完整输出,不折叠、不截断** —— 调试时就是要能逐条核对
|
||||||
|
> 1000+ 条曲目、104 条地图。如需限制,改 `config.py` 的 `LOG_VALUE_MAX`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、主脚本怎么写
|
||||||
|
|
||||||
|
### 4.1 ★ 核心原则:**不要为每个 api 调用写日志**
|
||||||
|
|
||||||
|
日志全部写在 api 脚本内部。主脚本**只需要**:
|
||||||
|
|
||||||
|
1. 开头 `setup_logging("脚本名")`
|
||||||
|
2. 照常调 api 函数
|
||||||
|
3. 结尾 `teardown_logging()`
|
||||||
|
|
||||||
|
**不要**在每次调用前写 `log_section`、调用后写 `log_info` —— 那是多余的,
|
||||||
|
api 函数自己会打印 `=== 函数名 开始运行 ===` / `[运行结果]` / `=== 函数名 运行结束 ===`。
|
||||||
|
|
||||||
|
> 完整范例见工程根目录的 `upsert.py`:整条「扫码 → 预览 → 登录 → 结算上传 → 登出」
|
||||||
|
> 只有四行业务代码,**一行日志都没写**。
|
||||||
|
|
||||||
|
### 4.2 最小可用版本
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import sys
|
||||||
|
from api import (setup_logging, teardown_logging,
|
||||||
|
ParseQRCode, UserPreview, UserLogin, UserLogout, LogoutType)
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
setup_logging("我的脚本") # ← 只需要这一句
|
||||||
|
try:
|
||||||
|
user_id, token = ParseQRCode(sys.argv[1])
|
||||||
|
UserPreview(user_id, token)
|
||||||
|
login = UserLogin(user_id, token)
|
||||||
|
UserLogout(user_id, token, login.loginDateTime,
|
||||||
|
logout_type=LogoutType.TestIn)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
teardown_logging()
|
||||||
|
```
|
||||||
|
|
||||||
|
**就这些。** 没有装饰器,没有埋点,没有 log_section。
|
||||||
|
|
||||||
|
### 4.3 嵌套调用也会自动记日志
|
||||||
|
|
||||||
|
`UpsertUserAll` 内部会调 `fetch_upsert_snapshot` / `UserNewItemList` / `UserData`,
|
||||||
|
这些**内部调用同样会打印自己的开始/结束行**(见 `upsert_user_all._resolve_api_func`),
|
||||||
|
所以结算链路的日志层次是完整嵌套的:
|
||||||
|
|
||||||
|
```
|
||||||
|
=== UpsertUserAll 开始运行 ===
|
||||||
|
[接口说明] 上传一局成绩(UpsertUserAllApi)—— 一个函数跑完整条结算链路。
|
||||||
|
=== fetch_upsert_snapshot 开始运行 ===
|
||||||
|
...
|
||||||
|
=== fetch_upsert_snapshot 运行结束 ===
|
||||||
|
=== UserNewItemList 开始运行 ===
|
||||||
|
...
|
||||||
|
=== UserNewItemList 运行结束 ===
|
||||||
|
...
|
||||||
|
=== UpsertUserAll 运行结束 ===
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 想额外记点自己的话(可选)
|
||||||
|
|
||||||
|
不写也完全不影响:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api import log_info, log_warning, log_error, log_section, log_field
|
||||||
|
|
||||||
|
log_section("阶段标题") # 带分隔线的分节
|
||||||
|
log_info("普通信息") # 就当成「会进日志文件的 print」
|
||||||
|
log_warning("需要留意的情况")
|
||||||
|
log_error("出错了")
|
||||||
|
log_field("预览数据", preview) # INFO 一行概要;DEBUG 展开完整 JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 ★ 保证登出
|
||||||
|
|
||||||
|
账号若停留在 `isLogin=True`,下次登录会 `returnCode=100`。用这个模板:
|
||||||
|
|
||||||
|
```python
|
||||||
|
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||||
|
try:
|
||||||
|
...
|
||||||
|
login = UserLogin(user_id, token)
|
||||||
|
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||||
|
...
|
||||||
|
UserLogout(...)
|
||||||
|
session["logged_in"] = False
|
||||||
|
finally:
|
||||||
|
if session["logged_in"]:
|
||||||
|
log_warning("[收尾] 异常中断,强制登出 ...")
|
||||||
|
try:
|
||||||
|
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||||
|
logout_type=LogoutType.TestIn)
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f"[收尾] 强制登出失败:{e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、api 脚本怎么写
|
||||||
|
|
||||||
|
**不用加装饰器**,直接定义函数就行:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# api/userpreview.py
|
||||||
|
from .log_setup import log_info, log_debug, log_request, log_response
|
||||||
|
from .maimai_net import request_dict
|
||||||
|
|
||||||
|
def UserPreview(user_id: int, token: str, *, client_id: str = CLIENT_ID):
|
||||||
|
"""获取用户预览信息。""" # ← 首行成为 [接口说明]
|
||||||
|
log_info(f"获取用户预览:user_id={user_id}") # 可选
|
||||||
|
|
||||||
|
raw = request_dict("GetUserPreviewApi", req, user_id=user_id, client_id=client_id)
|
||||||
|
# ↑ maimai_net 内部已经调了 log_request / log_response,
|
||||||
|
# DEBUG 模式下会自动输出收发数据,不用在这里重复写
|
||||||
|
|
||||||
|
return parse(raw)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新增 api 必须登记
|
||||||
|
|
||||||
|
写完新函数后,在 `api/__init__.py` 做两步:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1) 导入
|
||||||
|
from .xxx import MyNewApi
|
||||||
|
|
||||||
|
# 2) 加进跟踪名单
|
||||||
|
_API_FUNCS = (
|
||||||
|
"ParseQRCode",
|
||||||
|
"UserPreview",
|
||||||
|
...
|
||||||
|
"MyNewApi", # ← 加这里
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`autowrap(globals(), _API_FUNCS)` 会在导入时自动给它套上日志外壳。
|
||||||
|
**漏了这步,该函数就不会输出 `===开始运行===`。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、完整 API 参考
|
||||||
|
|
||||||
|
### 6.1 会话(主脚本用)
|
||||||
|
|
||||||
|
```python
|
||||||
|
setup_logging(script_name, level=None) -> str
|
||||||
|
# 初始化会话:确定模式、打开两个日志文件、打印横幅
|
||||||
|
# level=None 时读 config.LOG_LEVEL
|
||||||
|
# 同一进程重复调用安全(只初始化一次)
|
||||||
|
|
||||||
|
teardown_logging()
|
||||||
|
# 收尾:打印「本次运行调用的 api」清单 + 结束横幅 + 关文件
|
||||||
|
# 不调用也会在退出时自动执行
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 自动跟踪
|
||||||
|
|
||||||
|
```python
|
||||||
|
autowrap(namespace, names) -> list[str] # api/__init__.py 调用,一般不用手写
|
||||||
|
get_called_apis() -> list[str] # 本次会话调用过的 api,按首次调用顺序
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 模式
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_level() -> "info" | "debug"
|
||||||
|
set_level(v) -> "info" | "debug"
|
||||||
|
is_debug() -> bool
|
||||||
|
LEVEL_INFO = "info"
|
||||||
|
LEVEL_DEBUG = "debug"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 流程日志(INFO 可见)
|
||||||
|
|
||||||
|
```python
|
||||||
|
log_info("普通信息")
|
||||||
|
log_step("扫码登录") # → [步骤] 扫码登录
|
||||||
|
log_step("扫码登录", index=1, total=5) # → [步骤 1/5] 扫码登录
|
||||||
|
log_result("user_id=123") # → [结果] user_id=123
|
||||||
|
log_warning("账号已登录") # → [警告] ...
|
||||||
|
log_error("网络不可达") # → [错误] ...
|
||||||
|
log_section("阶段标题") # 带分隔线的分节
|
||||||
|
log_field("预览数据", preview) # INFO 一行;DEBUG 多行展开
|
||||||
|
log_field("原始响应", text, debug_only=True) # 只在 DEBUG 出现
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 收发数据日志(DEBUG 专属)
|
||||||
|
|
||||||
|
```python
|
||||||
|
log_debug("只在 DEBUG 出现")
|
||||||
|
log_request(url="https://...", body=req, headers=hdr, api_name="XxxApi")
|
||||||
|
log_response(status=200, body=raw, headers=hdr, api_name="XxxApi", elapsed_ms=412)
|
||||||
|
log_payload("解密后明文", text)
|
||||||
|
```
|
||||||
|
|
||||||
|
**INFO 模式下这些函数第一行就 `return`**,不做序列化、不拼字符串,可以放心留在热路径里。
|
||||||
|
|
||||||
|
### 6.6 兼容写法
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api import api_log, get_logger
|
||||||
|
|
||||||
|
@api_log # 仍可用(会自动跳过重复包装)
|
||||||
|
def MyFunc(): ...
|
||||||
|
|
||||||
|
logger = get_logger()
|
||||||
|
logger.info("..."); logger.warning("..."); logger.debug("...")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、日志文件
|
||||||
|
|
||||||
|
| 文件 | 打开模式 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `logs/latest.log` | `w`(覆盖) | 每次运行重写,**只保留最近一次** |
|
||||||
|
| `logs/YYYY-MM-DD.log` | `a`(追加) | **当日所有运行累积**,按天分文件 |
|
||||||
|
|
||||||
|
两者**同时写入**。每行都带 `[YYYY-MM-DD HH:MM:SS]` 时间戳。
|
||||||
|
|
||||||
|
DEBUG 的收发数据同样写进文件,可以跑一次 DEBUG 再翻 `latest.log`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、常见问题
|
||||||
|
|
||||||
|
**Q: 为什么 api 调用没有 `===开始运行===`?**
|
||||||
|
A: 该函数没登记在 `api/__init__.py` 的 `_API_FUNCS` 里。补上即可。
|
||||||
|
|
||||||
|
**Q: 为什么 `log_debug` 什么都没输出?**
|
||||||
|
A: 当前是 INFO 模式。改 `config.py` 的 `LOG_LEVEL = "debug"`。
|
||||||
|
|
||||||
|
**Q: 改了 config.py 没生效?**
|
||||||
|
A: 模式在 `setup_logging()` 时读取。之前已初始化过就需重启脚本,
|
||||||
|
或显式 `setup_logging("名字", level="debug")`。
|
||||||
|
|
||||||
|
**Q: 主脚本没调用 `setup_logging` 会怎样?**
|
||||||
|
A: 仍会打印到控制台(自动跟踪在导入时就生效),但不写文件、没有横幅与汇总。
|
||||||
|
|
||||||
|
**Q: 写测试 / 单独跑 api 脚本时不想要日志?**
|
||||||
|
A: 不调用 `setup_logging` 就不会开文件;控制台输出仍在。
|
||||||
|
|
||||||
|
**Q: 能完全关掉吗?**
|
||||||
|
A: 可以 `set_level("info")` 抑制 DEBUG 部分。要连控制台也静音需改 `_emit` 的 `console` 参数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、相关文件
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `api/log_setup.py` | 日志模块本体(含自动包装引擎) |
|
||||||
|
| `api/__init__.py` | 统一导出 + `_API_FUNCS` 登记表 |
|
||||||
|
| `config.py` | `LOG_LEVEL` / `LOG_VALUE_MAX` 开关 |
|
||||||
|
| `upsert.py` | 主脚本范例(含完整的登出兜底) |
|
||||||
|
| `api/maimai_net.py` | 网络层,已接入 DEBUG 收发数据日志 |
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# 真机 Hook 对照报告(MaimaiNetSpy 实测)
|
||||||
|
|
||||||
|
> 本文记录用 MelonLoader Mod **MaimaiNetSpy** 在真机上抓取一整局(登录 → 游玩 → 结算上报 → 登出)
|
||||||
|
> 的完整网络行为,并与本工程的实现逐项对照后做的修正。
|
||||||
|
>
|
||||||
|
> 抓包样本:
|
||||||
|
> - `D:\SDGB2026\Package\MaimaiNetSpy\2026-10-05_222919.log`(70 个请求的人类可读时间线)
|
||||||
|
> - `D:\SDGB2026\Package\MaimaiNetSpy\raw\*`(每个接口的请求/响应原文 JSON)
|
||||||
|
> - 关键样本:`raw/068_UpsertUserAllApiMaimaiChn_request.json`(17164 字节明文)
|
||||||
|
>
|
||||||
|
> Hook 挂载点(`gamehook/Main.cs`,8 个,全部 `[ok]`):
|
||||||
|
> `NetHttpClient.Create / AddHeader / AddCookie / Request / GetResponse`、
|
||||||
|
> `NetPacketUtil.GetUserAgent`、`GameManager.CalcSpecialNum`、`NetHttpClient.SetError`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结论速览
|
||||||
|
|
||||||
|
| 项 | 结论 |
|
||||||
|
|---|---|
|
||||||
|
| UpsertUserAll 报文内容 | **与真机逐字段一致**(顶层 7 键、36 节点、110 字段 playlog、11 条位串全部对齐) |
|
||||||
|
| 报文长度 | 真机 17164 B / 本工程 17164 B(同一基线重建) |
|
||||||
|
| 剩余差异 | 只有「随机数」和「时刻字符串」,见第五节 |
|
||||||
|
| HTTP 头 | `number` 已修正;`Cookie` 经实测为**无需处理**;其余一致 |
|
||||||
|
| `playSpecial` | 已按 `CalcSpecialNum = 1024` 修正 |
|
||||||
|
| `characterLevel1..5` | **发现并修掉一处回归**:应为游玩前等级,不加经验 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、`number` 请求头:真机不是恒 0,也不是每包都变
|
||||||
|
|
||||||
|
抓到的 70 个请求里 `number` 只有两次取值变化:
|
||||||
|
|
||||||
|
```
|
||||||
|
#1 ~ #10 number = 0 ← 还没跑过心跳
|
||||||
|
#11 ~ #17 这是 Ping 心跳包(按 Packet.cs:187 不带 number 头)
|
||||||
|
#18 ~ #70 number = 63 ← 心跳往返 63 ms,此后 53 个业务请求全部恒定
|
||||||
|
```
|
||||||
|
|
||||||
|
**源码链路**(逐条可查):
|
||||||
|
|
||||||
|
```
|
||||||
|
Main/PingChecker.cs:166 PacketHelper.StartPacket(new PacketPing(PingSuccess, PingError))
|
||||||
|
Net.Packet.Mai2/PacketPing.cs:34
|
||||||
|
_onDone((int)sw.ElapsedMilliseconds) ← 值 = Ping 包往返毫秒
|
||||||
|
Main/PingChecker.cs:227 GameManager.PingMsec = pingMsec
|
||||||
|
Net.Packet/Packet.cs:187-189 if (!IsPingPacket()) Client.AddHeader("number", GameManager.PingMsec.ToString());
|
||||||
|
```
|
||||||
|
|
||||||
|
即:`number` = `GameManager.PingMsec`,**只由 Ping 心跳刷新**,普通业务请求不改动它。
|
||||||
|
|
||||||
|
### 本工程的修正
|
||||||
|
|
||||||
|
`api/maimai_net.py`:
|
||||||
|
|
||||||
|
- 字段从 `last_ping_msec`(每个请求都更新)改成 **`ping_msec`(只有 Ping 包更新)**,
|
||||||
|
并保留 `last_ping_msec` 只读属性兼容旧代码。
|
||||||
|
- `request()` 里新增:`if api_name.startswith("Ping"): self.ping_msec = elapsed_ms`。
|
||||||
|
- `_build_headers()` 的 `ping_msec` 参数默认 `None`(= 取 `self.ping_msec`)。
|
||||||
|
- `upsert.py` 在取完服务器设置后加了一步 `Ping()`(真机也是在第一屏之后跑心跳)。
|
||||||
|
|
||||||
|
> 实测:本机到该服务器 Ping 往返约 **266 ms**,所以本脚本发出去的 `number` 会是那个值,
|
||||||
|
> 而不是真机的 63 —— 这是**网络环境差异,不是实现差异**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、`Cookie` 头:hook 显示的串是假的,但 Cookie 本身是真的
|
||||||
|
|
||||||
|
抓包日志里从 #18 起显示 `Cookie : userId=12345678`,一度被误认为「要手工带一个 userId Cookie」。
|
||||||
|
|
||||||
|
**核实过程(两步,第二步推翻了我自己的第一步结论)**:
|
||||||
|
|
||||||
|
1. `gamehook/Main.cs:196-199` 的 `PreAddCookie(ulong userId)` 里写的是
|
||||||
|
`_cookie = "userId=" + userId;` —— 这行是 **hook 自己拼的展示串**,
|
||||||
|
**不是真实的 HTTP Cookie 头**。
|
||||||
|
它出现的位置正好对应 `Packet.cs:190-193` 的
|
||||||
|
`if (Query.UserId != 0L) Client.AddCookie(Query.UserId);`,
|
||||||
|
只是「本次请求带了 userId」的意思。
|
||||||
|
|
||||||
|
2. **但 Cookie 机制本身是真实存在的**,而且很关键。
|
||||||
|
用 `Ping` 探针看不到 Set-Cookie(Ping 响应头只有
|
||||||
|
`Date / Content-Type / Content-Length / Connection / Pragma / Cache-Control`),
|
||||||
|
于是我先得出了「服务器从不下发 Cookie」的结论 —— **这是错的**。
|
||||||
|
后来跑完整登录链路时,`UserLoginApi` 的响应头里明确有:
|
||||||
|
```
|
||||||
|
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Packet.cs:228-234 if (Query.Api.StartsWith("UserLogin")) { cookieContainer.Add(Client.GetCookie());
|
||||||
|
SetCookie(Query.UserId, cookieContainer); }
|
||||||
|
Packet.cs:190-193 之后每个 userId != 0 的请求都 AddCookie(Query.UserId) 把容器挂回去
|
||||||
|
```
|
||||||
|
|
||||||
|
即:**登录时拿到的 JSESSIONID 会被后续所有请求带着** —— 这是真实的会话粘滞,
|
||||||
|
不是可有可无的装饰。
|
||||||
|
|
||||||
|
**结论**:`requests.Session` 会自动收下 `Set-Cookie` 并在后续请求自动带上,
|
||||||
|
行为与 C# 的 `CookieContainer` 完全一致,**因此仍然不需要写任何代码** ——
|
||||||
|
但前提是**必须复用同一个 `MaimaiNetClient`/`Session`**。
|
||||||
|
本工程的 `api/` 各函数在 `client=None` 时都走 `get_default_client()`(全局单例),
|
||||||
|
所以自动满足。如果谁手动 new 一个 `MaimaiNetClient` 去发结算包,就会丢掉 JSESSIONID。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、`playSpecial` 与 `CalcSpecialNum`
|
||||||
|
|
||||||
|
Hook 抓到 `Manager.GameManager.CalcSpecialNum()` 的返回值:
|
||||||
|
|
||||||
|
```
|
||||||
|
[CalcSpecialNum] = 1024 (SpecialAP=False IsFreedomMode=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
**源码**(`Manager/GameManager.cs:332-378`)中 `1024 = 0x400` 是**无条件置位**的那一位,
|
||||||
|
其余各位的条件在这台机台上恰好都不成立(ACA 机型、GTX 显卡、i3-8100 CPU 三条否定条件都为假)。
|
||||||
|
|
||||||
|
**反推验证**(`Net.VO.Mai2/VOExtensions.cs:846-855`):
|
||||||
|
|
||||||
|
```
|
||||||
|
真机 playSpecial = 892014272
|
||||||
|
位反转32(892014272) = 57726124 = 27900 × 2069 + 1024
|
||||||
|
└────┬────┘ └─┬─┘
|
||||||
|
随机系数 余数 = CalcSpecialNum ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
### 本工程的修正
|
||||||
|
|
||||||
|
- `config.py` 新增 `PLAY_SPECIAL_NUM = 1024`,注释里逐位列出 10 个标志位及源码行号。
|
||||||
|
- `calc_play_special(calc_special_num=None)` 用它替换原来的写死 `0`。
|
||||||
|
- `selftest.py` 新增反推断言:`位反转32(真机 playSpecial) % 2069 == 1024`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、逐字段比对的剩余差异(全部已解释)
|
||||||
|
|
||||||
|
用真机 `068` 报文**反向重建基线**后再跑本工程组包,逐路径 diff:
|
||||||
|
|
||||||
|
| 路径 | 真机 | 本工程 | 原因 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `upsertUserAll.userGamePlaylogList[0].playSpecial` | 892014272 | 随机 | **本来就是随机数**(`Random.Range(1,1037933)*2069+1024` 后位反转) |
|
||||||
|
| `upsertUserAll.userGamePlaylogList[0].playCount` | 10 | 8 | 机台**本地**计数器,服务器不返回,只能手填 `config.PLAY_LOCAL_PLAY_COUNT` |
|
||||||
|
| `userData[0].lastLoginDate` / `eventWatchedDate` | 22:31:23.0 | 22:31:22.0 | 差 1 秒:真机在「登录响应处理完那一刻」才写这个值,而 `loginDateTime` 是请求时刻 |
|
||||||
|
| `userPlaylogList[0].loginDate` | 1791210683 | 1791210682 | 同上,1 秒 |
|
||||||
|
| `userPlaylogList[0].userPlayDate` | 22:31:59.0 | 22:32:21.0 | 真机是「本局打完那一刻」,脚本没有真实游玩过程,用上报时刻代替 |
|
||||||
|
|
||||||
|
除上表外,**顶层 7 键、36 个节点名、各节点条数、userData 全部键名、11 条位串,两个方向都是 0 差异。**
|
||||||
|
|
||||||
|
> 1 秒级差异已由真机自身证明无害:同一账号两个会话里
|
||||||
|
> `userPlaylog.loginDate - loginDateTime` 分别是 **-28798**(19:21 会话)和 **-28799**(22:31 会话),
|
||||||
|
> 服务端两次都返回 `{"returnCode":1}` 并正常落库 —— 它不对这几个时刻做等式校验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、本轮修正清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `api/maimai_net.py` | `number` 头改为只由 Ping 刷新;补 `last_ping_msec` 兼容属性;docstring 写清 number/Cookie 语义 |
|
||||||
|
| `api/upsert_user_all.py` | ① `calc_play_special` 支持 `CalcSpecialNum`(默认 1024)<br>② **修掉 `characterLevelN` 加经验的回归**,改为游玩前快照<br>③ 新增 `derive_play_characters(snapshot)`:从服务器快照推游玩前角色<br>④ 新增 `play_date_time` 参数,把「登录时刻」与「游玩/上报时刻」拆开<br>⑤ `build_favorite_delta` 新增 `assume_missing`,区分「原始基线」与「归一化基线」 |
|
||||||
|
| `config.py` | 新增 `PLAY_SPECIAL_NUM`;补 `PLAY_CHARACTERS` / `PLAY_LOCAL_PLAY_COUNT` 的用法与实测值 |
|
||||||
|
| `api/ping.py` | docstring 写清「Ping 是 number 头的唯一来源」 |
|
||||||
|
| `upsert.py` | 加 `Ping()` 步骤;用 `derive_play_characters` 覆盖手填角色;传 `play_date_time=login_date_time_now()` |
|
||||||
|
| `api/__init__.py` | 导出 `derive_play_characters` |
|
||||||
|
| `selftest.py` | 148 → 155 项;新增 CalcSpecialNum 反推、角色快照、登录/游玩时刻分离等断言 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、关于 `characterLevelN` 的回归(重点)
|
||||||
|
|
||||||
|
`Net.VO.Mai2/VOExtensions.cs:1361`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
result.characterLevel1 = (int)gameScore.CharaLevel[0];
|
||||||
|
```
|
||||||
|
|
||||||
|
`gameScore.CharaLevel` 是**本次开始游玩时**灌进去的快照,整局不变 ——
|
||||||
|
所以 `userPlaylog.characterLevelN` 是**游玩前**等级,游玩后的等级只出现在
|
||||||
|
`upsertUserAll.userCharacterList` 里。
|
||||||
|
|
||||||
|
**两个真机会话的三方对照**(Get 侧 / playlog / upsertUserCharacterList):
|
||||||
|
|
||||||
|
| 会话 | GetUserCharacterApi | userPlaylogList[0] | userCharacterList |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `source/2026-10-05-new.log`(19:21) | 395=**3801** 101=**4456** 135 135 135 | **3801 4456 135 135 135** | 395=**3802** 101=**4457** 136 136 136 |
|
||||||
|
| MaimaiNetSpy `2026-10-05_222919`(22:31) | 395=**3802** 101=**4457** 136 136 136 | **3802 4457 136 136 136** | 395=**3803** 101=**4458** 137 137 137 |
|
||||||
|
|
||||||
|
**playlog 恒等于 Get 侧(游玩前),userCharacterList 恒为 +1(游玩后)。**
|
||||||
|
|
||||||
|
代码里曾经是 `level + character_exp_gain`,现已改回 `level`。
|
||||||
|
|
||||||
|
为了避免手填的 `config.PLAY_CHARACTERS` 随账号升级而过期,
|
||||||
|
新增 `derive_play_characters(snapshot)`:直接读快照里的
|
||||||
|
`userData.charaSlot`(5 个槽位,顺序即 characterId1..5)与
|
||||||
|
`userCharacterList`(每只角色的 level / awakening),`upsert.py` 已接上。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、真实发包验证(✅ 已完成,2026-10-06 01:28)
|
||||||
|
|
||||||
|
`upsert.py <二维码> --send` 用上面这套实现真实上报了一局成绩,
|
||||||
|
**服务端返回 returnCode=1,且重新拉数据确认 playCount 1260→1261 —— 真正落库了**。
|
||||||
|
完整证据见 [`docs/实跑验证记录.md`](实跑验证记录.md)。
|
||||||
|
|
||||||
|
流程与真机一致:
|
||||||
|
|
||||||
|
```
|
||||||
|
GetGameSetting -> Ping -> ParseQRCode -> GetUserPreview -> UserLogin
|
||||||
|
-> fetch_upsert_snapshot(19 个 Get 节点)
|
||||||
|
-> GetUserNewItemList(游玩后档案)
|
||||||
|
-> UpsertUserAll(★ 有副作用)
|
||||||
|
-> UserLogout
|
||||||
|
```
|
||||||
|
|
||||||
|
安全闸门保持默认关闭:不传 `--send` 时只构造报文(`dry_run`),
|
||||||
|
`UpsertUserAll(confirm=None)` 会读取 `config.UPSERT_CONFIRM = False` 并抛 `ConfirmRequiredError`。
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
# 舞萌DX(maimai DX)扫码登录协议技术规范
|
||||||
|
|
||||||
|
> **来源文档**:
|
||||||
|
> - `docs-old/舞萌DX_aimedb调用链分析.md` —— 完整调用链、二维码格式、ChimeLib/aimedb 关系
|
||||||
|
> - `docs-old/chimelib协议逆向.md` —— 纯网络实现细节、签名算法、抓包验证、关键坑点
|
||||||
|
>
|
||||||
|
> **适用场景**:纯网络(纯 HTTP)复刻舞萌DX国服微信扫码登录,获取 userId 与 token
|
||||||
|
> **协议路径**:路径 A —— 微信扫码(HTTPS + JSON,`chimelib_dll.dll` + WinHTTP)
|
||||||
|
> **非适用**:路径 B —— 传统 Aime 物理卡(TCP 二进制,`amdaemon_api.dll` + IPC)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、架构总览
|
||||||
|
|
||||||
|
### 1.1 两条 aimedb 通信路径
|
||||||
|
| 路径 | 名称 | 协议 | 核心组件 | 你实际用的 |
|
||||||
|
|------|------|------|----------|------------|
|
||||||
|
| **A** | 微信扫码 | HTTPS + JSON | `chimelib_dll.dll` (WinHTTP) | ✅ **是** |
|
||||||
|
| B | 传统 Aime 物理卡 | TCP 二进制 | `amdaemon_api.dll` (IPC) | ❌ 否 |
|
||||||
|
|
||||||
|
### 1.2 调用链(路径 A)
|
||||||
|
```
|
||||||
|
游戏客户端 Assembly-CSharp
|
||||||
|
│ ChimeReaderManager (驱动扫码、取结果)
|
||||||
|
▼
|
||||||
|
ChimeLib.NET.dll (托管封装)
|
||||||
|
│ Chime / ChimeUnit / ChimeResult / CCommGetUserData / CCommServAliveCheck
|
||||||
|
▼
|
||||||
|
chimelib_dll.dll (x64 原生,62KB,静态链接 WinHTTP)
|
||||||
|
│ CCommGetUserData_Create/Execute/IsEnd/GetUserID/GetToken
|
||||||
|
│ CCommServAliveCheck_Create/StartCheck/Execute/GetIndex/IsAlive
|
||||||
|
▼
|
||||||
|
aimedb 服务器集群
|
||||||
|
│ https://ai.sys-all.cn/ (主)
|
||||||
|
│ https://ai.sys-allnet.cn/ (备)
|
||||||
|
▼
|
||||||
|
POST /wc_aime/api/get_data → 返回 userID + token
|
||||||
|
POST /wc_aime/api/alive_check → 返回 "alive"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 关键数据流向
|
||||||
|
```
|
||||||
|
二维码串(SGWCMAID...)
|
||||||
|
→ ChimeLib.NET.CCommQRCode.IsValid() 校验
|
||||||
|
→ EraseHashIdentifier() 取 [20:] 64位载荷
|
||||||
|
→ CCommGetUserData_Create(strGameID="MAID", strChipID, strCommonKey="", strQRData=载荷, strTitleKey="SDGB", serverURLIndex)
|
||||||
|
→ WinHTTP HTTPS POST /wc_aime/api/get_data
|
||||||
|
→ 响应 {errorID, userID, token, key, timestamp}
|
||||||
|
→ ChimeResult.GetChimeId() → userId (uint)
|
||||||
|
→ ChimeResult.GetToken() → token (string)
|
||||||
|
→ OperationManager.SetToken(userId, token) 缓存
|
||||||
|
→ 后续游戏服务器 API 携带 token
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、二维码格式规范
|
||||||
|
|
||||||
|
### 2.1 完整二维码字符串(84 字符)
|
||||||
|
```
|
||||||
|
结构:SGWC(4) + MAID(4) + timestamp(12) + payload(64 hex) = 84 字符
|
||||||
|
|
||||||
|
偏移 长度 字段 说明 示例
|
||||||
|
0–3 4 微信标识 常量 "SGWC" SGWC
|
||||||
|
4–7 4 GameID "MAID"=舞萌DX MAID
|
||||||
|
8–19 12 时间戳 yymmddHHMMss (UTC+9) 260613133715
|
||||||
|
20–83 64 载荷/哈希 64位十六进制(疑似SHA-256) 77503FD2...427759BCA5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 校验规则(`CCommQRCode.IsValid()`)
|
||||||
|
```python
|
||||||
|
def is_valid(qr: str) -> bool:
|
||||||
|
return len(qr) == 84 and qr[0:4] == "SGWC" and qr[4:8] == "MAID"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 传给原生层的数据(`EraseHashIdentifier()`)
|
||||||
|
```python
|
||||||
|
def erase_hash_identifier(qr: str) -> str:
|
||||||
|
"""去掉前 20 字符,返回 64 位载荷"""
|
||||||
|
return qr[20:] # 即 payload 部分
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 二维码 URL ⇄ 字符串互转
|
||||||
|
**URL 格式**:
|
||||||
|
```
|
||||||
|
https://wq.wahlap.net/qrcode/req/{GameID+timestamp+payload}.html?l=...&t=...&d=...
|
||||||
|
└──────────── 80 字符 ────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**提取规则**:
|
||||||
|
```python
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import re
|
||||||
|
|
||||||
|
def url_to_qr_string(url: str) -> str:
|
||||||
|
"""URL → 完整 84 字符二维码串"""
|
||||||
|
path = urlparse(url).path
|
||||||
|
match = re.search(r"/qrcode/req/([^/]+)\.html", path)
|
||||||
|
if not match:
|
||||||
|
raise ValueError("URL 路径格式不符")
|
||||||
|
return "SGWC" + match.group(1) # 前加 SGWC 前缀
|
||||||
|
|
||||||
|
def qr_string_to_url_body(qr: str) -> str:
|
||||||
|
"""完整二维码串 → URL 路径段(80字符,无 SGWC)"""
|
||||||
|
return qr[4:] # 去掉 SGWC
|
||||||
|
```
|
||||||
|
|
||||||
|
**查询参数**(`l`/`t`/`d`):**不参与**机台协议,仅为微信侧生成二维码时的元数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、服务器端点与网络协议
|
||||||
|
|
||||||
|
### 3.1 服务器地址
|
||||||
|
| 环境 | 域名 | IP | 端口 | TLS |
|
||||||
|
|------|------|-----|------|-----|
|
||||||
|
| 主 | `ai.sys-all.cn` | 81.71.193.236 | 443 | TLSv1.2 |
|
||||||
|
| 备 | `ai.sys-allnet.cn` | (同解析) | 443 | TLSv1.2 |
|
||||||
|
|
||||||
|
### 3.2 API 端点
|
||||||
|
| 端点 | Method | Content-Type | User-Agent | 用途 |
|
||||||
|
|------|--------|--------------|------------|------|
|
||||||
|
| `/wc_aime/api/alive_check` | POST | `application/json` (body `{}`) | 无 | 探活,返回 `200 alive` |
|
||||||
|
| `/qrcode/api/alive_check` | POST | 无(空body) | `WC_AIME_LIB` | 探活变体,返回 `200 alive` |
|
||||||
|
| `/wc_aime/api/get_data` | POST | **无** | `WC_AIME_LIB` | **核心登录接口** |
|
||||||
|
|
||||||
|
### 3.3 请求头(关键:无 Content-Type)
|
||||||
|
```http
|
||||||
|
POST /wc_aime/api/get_data HTTP/1.1
|
||||||
|
Host: ai.sys-allnet.cn
|
||||||
|
User-Agent: WC_AIME_LIB
|
||||||
|
Connection: Keep-Alive
|
||||||
|
Content-Length: 243
|
||||||
|
|
||||||
|
{"chipID":"...","openGameID":"MAID","key":"...","qrCode":"...","timestamp":"...","titlekey":"SDGB"}
|
||||||
|
```
|
||||||
|
> ⚠️ **严禁添加 `Content-Type: application/json`**,否则服务端返回 `errorID=57`(HTTP 200 + 合法 JSON,极易误判为签名错误)。
|
||||||
|
|
||||||
|
### 3.4 请求体 JSON 字段
|
||||||
|
| 字段 | 类型 | 来源 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `chipID` | string | Keychip ID | 机台唯一标识,如 `XXXX-XXXXXXXXXXX` |
|
||||||
|
| `openGameID` | string | 固定 | `"MAID"` |
|
||||||
|
| `key` | string | 签名计算 | SHA-256 大写 hex,**核心签名字段** |
|
||||||
|
| `qrCode` | string | 二维码[20:] | **64 位载荷,非完整 84 字符串** |
|
||||||
|
| `timestamp` | string | 本机本地时间 | yymmddHHMMss 格式 |
|
||||||
|
| `titlekey` | string | 固定 | `"SDGB"` |
|
||||||
|
|
||||||
|
### 3.5 响应体 JSON 字段
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `errorID` | int | 0=成功,非0=失败 |
|
||||||
|
| `userID` | int | 成功时为正整数(如 12345678),失败时 -1 |
|
||||||
|
| `token` | string | 成功时 20 位临时凭据,失败时空串 |
|
||||||
|
| `key` | string | 服务端返回的签名(可忽略) |
|
||||||
|
| `timestamp` | string | 服务端时间戳 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、签名算法(核心)
|
||||||
|
|
||||||
|
### 4.1 算法规范
|
||||||
|
```python
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW" # 32字符,含前导 X
|
||||||
|
|
||||||
|
def calculate_key(chip_id: str, timestamp: str) -> str:
|
||||||
|
"""
|
||||||
|
key = SHA-256(chipID + timestamp + SIGN_KEY).hexdigest().upper()
|
||||||
|
|
||||||
|
参数:
|
||||||
|
chip_id: 如 "XXXX-XXXXXXXXXXX"
|
||||||
|
timestamp: 如 "261001004115" (yymmddHHMMss)
|
||||||
|
|
||||||
|
返回:64字符大写十六进制
|
||||||
|
"""
|
||||||
|
sign_input = chip_id + timestamp + SIGN_KEY
|
||||||
|
return hashlib.sha256(sign_input.encode()).hexdigest().upper()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 技术细节
|
||||||
|
| 项目 | 值 | 验证来源 |
|
||||||
|
|------|-----|----------|
|
||||||
|
| 哈希算法 | SHA-256 | Hook `CryptCreateHash` ALGID=0x800C (CALG_SHA_256) |
|
||||||
|
| 密钥串 | `XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW` | .rdata UTF-16 @0x00B040,**含前导 X** |
|
||||||
|
| 拼接顺序 | `chipID + timestamp + KEY` | **不含 qrCode、不含 titlekey** |
|
||||||
|
| 输出编码 | 大写十六进制 | hex 表 `0123456789ABCDEF` @0x00A440 |
|
||||||
|
|
||||||
|
### 4.3 抓包验证向量
|
||||||
|
```
|
||||||
|
输入:
|
||||||
|
chipID = "XXXX-XXXXXXXXXXX"
|
||||||
|
timestamp = "261001004115"
|
||||||
|
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
|
||||||
|
|
||||||
|
SHA256("XXXX-XXXXXXXXXXX" + "261001004115" + "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW")
|
||||||
|
= 67015F28CFC3C7FA5E629BECE54893B581A6DBD153426AE5E6BA1681C6D8E327
|
||||||
|
```
|
||||||
|
与 mitmproxy 抓包完全一致 ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、完整请求流程(纯 Python 实现)
|
||||||
|
|
||||||
|
### 5.1 最小可用代码
|
||||||
|
```python
|
||||||
|
import hashlib, http.client, json, ssl
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
|
||||||
|
CHIP_ID = "XXXX-XXXXXXXXXXX" # 可配置
|
||||||
|
HOST = "ai.sys-allnet.cn"
|
||||||
|
PATH = "/wc_aime/api/get_data"
|
||||||
|
|
||||||
|
def parse_qr_login(qr_input: str, chip_id: str = CHIP_ID) -> tuple[int, str]:
|
||||||
|
"""
|
||||||
|
输入:二维码串(84字符)或 URL
|
||||||
|
返回:(user_id: int, token: str)
|
||||||
|
"""
|
||||||
|
# 1. 统一输入 → 标准 84 字符串
|
||||||
|
qr_string = normalize_qr_input(qr_input)
|
||||||
|
|
||||||
|
# 2. 校验格式
|
||||||
|
validate_qr_string(qr_string)
|
||||||
|
|
||||||
|
# 3. 构造请求体
|
||||||
|
ts = datetime.now().strftime("%y%m%d%H%M%S")
|
||||||
|
key = calculate_key(chip_id, ts)
|
||||||
|
qr_code = qr_string[20:] # 仅 64 位载荷
|
||||||
|
|
||||||
|
body_dict = {
|
||||||
|
"chipID": chip_id,
|
||||||
|
"openGameID": "MAID",
|
||||||
|
"key": key,
|
||||||
|
"qrCode": qr_code,
|
||||||
|
"timestamp": ts,
|
||||||
|
"titlekey": "SDGB",
|
||||||
|
}
|
||||||
|
body_bytes = json.dumps(body_dict, separators=(",", ":")).encode()
|
||||||
|
|
||||||
|
# 4. 发送请求(关键:无 Content-Type)
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
|
conn = http.client.HTTPSConnection(HOST, 443, context=ctx, timeout=20)
|
||||||
|
try:
|
||||||
|
conn.request("POST", PATH, body=body_bytes, headers={
|
||||||
|
"Connection": "Keep-Alive",
|
||||||
|
"User-Agent": "WC_AIME_LIB",
|
||||||
|
"Content-Length": str(len(body_bytes)),
|
||||||
|
# ★ 绝不加 Content-Type
|
||||||
|
})
|
||||||
|
resp = conn.getresponse()
|
||||||
|
data = json.loads(resp.read().decode())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# 5. 解析响应
|
||||||
|
if data.get("errorID") != 0 or data.get("userID") == -1:
|
||||||
|
raise RuntimeError(f"登录失败: errorID={data.get('errorID')}, userID={data.get('userID')}")
|
||||||
|
|
||||||
|
return data["userID"], data["token"]
|
||||||
|
|
||||||
|
def normalize_qr_input(qr_input: str) -> str:
|
||||||
|
"""URL 或字符串 → 标准 84 字符二维码串"""
|
||||||
|
qr = qr_input.strip()
|
||||||
|
if qr.startswith("http://") or qr.startswith("https://"):
|
||||||
|
qr = url_to_qr_string(qr)
|
||||||
|
return qr
|
||||||
|
|
||||||
|
def validate_qr_string(qr: str) -> None:
|
||||||
|
"""校验二维码格式,不通过抛 ValueError"""
|
||||||
|
if len(qr) != 84:
|
||||||
|
raise ValueError(f"长度应为84,实际{len(qr)}")
|
||||||
|
if not qr.startswith("SGWC"):
|
||||||
|
raise ValueError("必须以 SGWC 开头")
|
||||||
|
if qr[4:8] != "MAID":
|
||||||
|
raise ValueError(f"GameID 必须为 MAID,实际 {qr[4:8]}")
|
||||||
|
# 可选:校验 payload 为 64 位 hex
|
||||||
|
if not all(c in "0123456789ABCDEF" for c in qr[20:]):
|
||||||
|
raise ValueError("载荷必须为大写 hex")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 关键实现要点清单
|
||||||
|
| 要点 | 正确做法 | 错误做法后果 |
|
||||||
|
|------|----------|--------------|
|
||||||
|
| qrCode 字段 | `qr_string[20:]` (64位) | 传完整 84 字符 → 签名不匹配 |
|
||||||
|
| 签名拼接 | `chipID + timestamp + KEY` | 加入 qrCode/titlekey → 签名错误 |
|
||||||
|
| 密钥串 | 含前导 `X` (32字符) | 漏掉 X → 签名完全不同 |
|
||||||
|
| 请求头 | 无 `Content-Type` | 加上 → `errorID=57` |
|
||||||
|
| timestamp | 本机本地时间即可 | 纠结时区 → 其实服务端不强校验 |
|
||||||
|
| SSL 验证 | 关闭验证 | 开启 → 证书链可能不通过 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、错误码对照
|
||||||
|
|
||||||
|
| errorID | 含义 | 处理建议 |
|
||||||
|
|---------|------|----------|
|
||||||
|
| 0 | 成功 | 正常返回 userID + token |
|
||||||
|
| 1-8 | 原生 ChimeErrorId | 读卡器/服务器/二维码无效等通用错误 |
|
||||||
|
| **57** | **二维码已失效/已被使用/请求头不符** | 检查:二维码是否过期(~10分钟)、是否重复提交、**请求头是否含 Content-Type** |
|
||||||
|
| `userID:-1` | 失败标志 | 配合 errorID 判断具体原因 |
|
||||||
|
|
||||||
|
> ### ★ 实测补充(2026-10-06)
|
||||||
|
>
|
||||||
|
> 本工程用同一套参数实测到的失败响应是 **`errorID=2`**(不是 57):
|
||||||
|
>
|
||||||
|
> ```json
|
||||||
|
> {"errorID": 2, "key": "B744ADD5...", "timestamp": "261006125706",
|
||||||
|
> "userID": -1, "token": ""}
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **触发条件是「二维码过期」,不是「用过一次」。**
|
||||||
|
>
|
||||||
|
> | 二维码生成时刻(timestamp 内嵌) | 使用时刻 | 结果 |
|
||||||
|
> |---|---|---|
|
||||||
|
> | `261006012047`(01:20:47) | 01:21:55(+68s) | ✅ 成功 |
|
||||||
|
> | `261006124637`(12:46:37) | 12:52:22(+5m45s) | ✅ 成功 |
|
||||||
|
> | `261006130621`(13:06:21) | 13:10:42(+4m21s) | ✅ 成功 |
|
||||||
|
> | 上面那个 12:46:37 的二维码 | 13:0x(+约 15min) | ❌ `errorID=2` |
|
||||||
|
>
|
||||||
|
> 即:**二维码是「有时效」的,不是「按次数计量」的**。
|
||||||
|
>
|
||||||
|
> **有效期 = 10 分钟**(实测至少 5 分 45 秒内均可用,超时后返回 `errorID=2`)。
|
||||||
|
> 所以报错时**不要怀疑「用过了」**,重新生成一个即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、时间戳处理细节
|
||||||
|
|
||||||
|
| 时间戳位置 | 时区 | 格式 | 说明 |
|
||||||
|
|------------|------|------|------|
|
||||||
|
| 二维码内 (偏移8-19) | **UTC+9 (日本时间)** | yymmddHHMMss | 微信侧生成,约 10 分钟有效期 |
|
||||||
|
| 请求体 `timestamp` | **本机本地时间** | yymmddHHMMss | 实测 UTC+8 与 DLL 行为一致,服务端不强校验时区 |
|
||||||
|
| 响应 `timestamp` | 服务端时间 | yymmddHHMMss | 仅作参考 |
|
||||||
|
|
||||||
|
> **注意**:本机时区非 UTC+8 时,请求体 timestamp 仍用本地时间,但需确保系统时间准确(NTP 同步)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、探活接口(可选,纯 Python 可独立实现)
|
||||||
|
|
||||||
|
### 8.1 探活请求
|
||||||
|
```python
|
||||||
|
def check_alive(host: str = "ai.sys-allnet.cn") -> bool:
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
|
conn = http.client.HTTPSConnection(host, 443, context=ctx, timeout=10)
|
||||||
|
try:
|
||||||
|
conn.request("POST", "/wc_aime/api/alive_check", body=b"{}", headers={
|
||||||
|
"Content-Type": "application/json", # 探活需加 Content-Type
|
||||||
|
"Connection": "Keep-Alive",
|
||||||
|
})
|
||||||
|
resp = conn.getresponse()
|
||||||
|
return resp.status == 200 and resp.read().decode().strip() == "alive"
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 行为差异
|
||||||
|
| 端点 | Content-Type | User-Agent | 响应 |
|
||||||
|
|------|--------------|------------|------|
|
||||||
|
| `/wc_aime/api/alive_check` | `application/json` | 无 | `200 alive` |
|
||||||
|
| `/qrcode/api/alive_check` | 无 | `WC_AIME_LIB` | `200 alive` |
|
||||||
|
| `/wc_aime/api/get_data` | **无** | `WC_AIME_LIB` | 业务响应 |
|
||||||
|
|
||||||
|
> DLL 实测:`CCommServAliveCheck` 探活阶段**不发真实网络请求**(WinHTTP hook 不触发),纯 Python 可直接调用 alive_check 验证服务器存活。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、原生 DLL 接口签名(供 P/Invoke 参考)
|
||||||
|
|
||||||
|
### 9.1 CCommServAliveCheck 系列
|
||||||
|
```c
|
||||||
|
// 创建探活会话
|
||||||
|
HANDLE CCommServAliveCheck_Create();
|
||||||
|
|
||||||
|
// 设置连接权限
|
||||||
|
void CCommServAliveCheck_setConnectPermission(HANDLE h, BOOL permit);
|
||||||
|
|
||||||
|
// 开始探活
|
||||||
|
void CCommServAliveCheck_startCheck(HANDLE h);
|
||||||
|
|
||||||
|
// 执行轮询
|
||||||
|
void CCommServAliveCheck_execute(HANDLE h);
|
||||||
|
|
||||||
|
// 是否忙碌
|
||||||
|
BOOL CCommServAliveCheck_isBusy(HANDLE h);
|
||||||
|
|
||||||
|
// 获取存活服务器索引
|
||||||
|
int CCommServAliveCheck_getIndex(HANDLE h);
|
||||||
|
|
||||||
|
// 是否存活
|
||||||
|
BOOL CCommServAliveCheck_isAlive(HANDLE h);
|
||||||
|
|
||||||
|
// 销毁
|
||||||
|
void CCommServAliveCheck_Destroy(HANDLE h);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 CCommGetUserData 系列
|
||||||
|
```c
|
||||||
|
// 创建登录会话
|
||||||
|
// strGameID="MAID", strChipID=KeychipID, strCommonKey="", strQRData=二维码[20:], strTitleKey="SDGB", serverURLIndex=探活返回索引
|
||||||
|
HANDLE CCommGetUserData_Create(
|
||||||
|
LPCSTR strGameID,
|
||||||
|
LPCSTR strChipID,
|
||||||
|
LPCSTR strCommonKey,
|
||||||
|
LPCSTR strQRData,
|
||||||
|
LPCSTR strTitleKey,
|
||||||
|
int serverURLIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
// 执行请求
|
||||||
|
void CCommGetUserData_execute(HANDLE h);
|
||||||
|
|
||||||
|
// 是否结束
|
||||||
|
BOOL CCommGetUserData_isEnd(HANDLE h);
|
||||||
|
|
||||||
|
// 获取错误码 (0=成功)
|
||||||
|
int CCommGetUserData_getErrorID(HANDLE h);
|
||||||
|
|
||||||
|
// 获取 userID (uint)
|
||||||
|
unsigned int CCommGetUserData_getUserID(HANDLE h);
|
||||||
|
|
||||||
|
// 获取 token (ANSI 字符串)
|
||||||
|
LPCSTR CCommGetUserData_getToken(HANDLE h);
|
||||||
|
|
||||||
|
// 销毁
|
||||||
|
void CCommGetUserData_Destroy(HANDLE h);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 托管层调用序列(ChimeUnit.Execute 状态机)
|
||||||
|
```csharp
|
||||||
|
// 1. 探活
|
||||||
|
var alive = CCommServAliveCheck_Create();
|
||||||
|
CCommServAliveCheck_setConnectPermission(alive, true);
|
||||||
|
CCommServAliveCheck_startCheck(alive);
|
||||||
|
while (CCommServAliveCheck_isBusy(alive)) CCommServAliveCheck_execute(alive);
|
||||||
|
int urlIndex = CCommServAliveCheck_getIndex(alive);
|
||||||
|
bool isAlive = CCommServAliveCheck_isAlive(alive);
|
||||||
|
CCommServAliveCheck_Destroy(alive);
|
||||||
|
|
||||||
|
// 2. 登录
|
||||||
|
var login = CCommGetUserData_Create(
|
||||||
|
"MAID", // gameID
|
||||||
|
keychipId, // chipID (如 XXXX-XXXXXXXXXXX)
|
||||||
|
"", // commonKey (托管层传空,原生层内部处理)
|
||||||
|
qrString.Substring(20), // qrData (64位载荷)
|
||||||
|
"SDGB", // titleKey
|
||||||
|
urlIndex // 探活返回的服务器索引
|
||||||
|
);
|
||||||
|
while (!CCommGetUserData_isEnd(login)) CCommGetUserData_execute(login);
|
||||||
|
if (CCommGetUserData_getErrorID(login) == 0) {
|
||||||
|
uint userId = CCommGetUserData_getUserID(login);
|
||||||
|
string token = CCommGetUserData_getToken(login); // PtrToStringAnsi
|
||||||
|
}
|
||||||
|
CCommGetUserData_Destroy(login);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、常量汇总表
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ===== 签名与密钥 =====
|
||||||
|
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW" # 含前导 X,32字符
|
||||||
|
|
||||||
|
# ===== 默认机台标识 =====
|
||||||
|
DEFAULT_CHIP_ID = "XXXX-XXXXXXXXXXX"
|
||||||
|
|
||||||
|
# ===== 服务器 =====
|
||||||
|
PRIMARY_HOST = "ai.sys-all.cn"
|
||||||
|
BACKUP_HOST = "ai.sys-allnet.cn"
|
||||||
|
PORT = 443
|
||||||
|
LOGIN_PATH = "/wc_aime/api/get_data"
|
||||||
|
ALIVE_PATH = "/wc_aime/api/alive_check"
|
||||||
|
|
||||||
|
# ===== 固定请求字段 =====
|
||||||
|
OPEN_GAME_ID = "MAID"
|
||||||
|
TITLE_KEY = "SDGB"
|
||||||
|
USER_AGENT = "WC_AIME_LIB"
|
||||||
|
|
||||||
|
# ===== 二维码常量 =====
|
||||||
|
QR_PREFIX = "SGWC"
|
||||||
|
QR_GAME_ID = "MAID"
|
||||||
|
QR_PREFIX_LEN = 20 # SGWC(4) + MAID(4) + timestamp(12)
|
||||||
|
QR_PAYLOAD_LEN = 64
|
||||||
|
QR_TOTAL_LEN = 84
|
||||||
|
|
||||||
|
# ===== HTTP 头(登录接口) =====
|
||||||
|
LOGIN_HEADERS = {
|
||||||
|
"Connection": "Keep-Alive",
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
# "Content-Type": "application/json", # ★ 绝不加这一行
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、抓包调试方法论
|
||||||
|
|
||||||
|
### 11.1 强制走代理
|
||||||
|
```csharp
|
||||||
|
// Hook WinHttpOpen
|
||||||
|
// dwAccessType = 3 (WINHTTP_ACCESS_TYPE_NAMED_PROXY)
|
||||||
|
// pwszProxy = "127.0.0.1:8890"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 mitmproxy CA 证书安装
|
||||||
|
```powershell
|
||||||
|
# 必须用 X509Store 静默导入,Import-Certificate 会因 UI 限制失败
|
||||||
|
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store("Root", "CurrentUser")
|
||||||
|
$store.Open("ReadWrite")
|
||||||
|
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("mitmproxy-ca-cert.pem")
|
||||||
|
$store.Add($cert)
|
||||||
|
$store.Close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 落盘与读取
|
||||||
|
```bash
|
||||||
|
mitmdump -w flow.mitm
|
||||||
|
# Python 读取
|
||||||
|
from mitmproxy.io import FlowReader
|
||||||
|
with open("flow.mitm", "rb") as f:
|
||||||
|
for flow in FlowReader(f).stream():
|
||||||
|
print(flow.request.pretty_url)
|
||||||
|
print(flow.request.content)
|
||||||
|
print(flow.response.content)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.4 捕获签名输入
|
||||||
|
```csharp
|
||||||
|
// Hook CryptHashData 导出入口
|
||||||
|
// 捕获 pbData 指针指向的字节数组 → 即签名原文
|
||||||
|
// 对比:chipID + timestamp + KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.5 常见 Hook 坑点
|
||||||
|
| 坑点 | 原因 | 解决 |
|
||||||
|
|------|------|------|
|
||||||
|
| IAT hook 全失效 | pefile `imp.address` 已是完整 VA,再加 ImageBase 导致地址错误 | 直接用 `imp.address` + ASLR_delta |
|
||||||
|
| IAT 写入后还原 | CrackProof 驱动写保护页面 | 改用全局 inline hook:改写 advapi32 导出函数入口 + capstone 精确 trampoline |
|
||||||
|
| x64 trampoline 崩 | 指令切碎不完整 | capstone 逐条解析,累计 >=14 字节再跳转 |
|
||||||
|
| 误判加壳 | Hook 全失效以为加壳 | 实测:.text 熵 6.2、465 个 FF 15、入口点可正常反汇编 → **未加壳** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、实测验证记录
|
||||||
|
|
||||||
|
| # | 二维码时间戳 | userID | token (20位) | 备注 |
|
||||||
|
|---|--------------|--------|--------------|------|
|
||||||
|
| 1 | 260925205225 | 12345678 | TESTTOKEN0000000000 | DLL 验证成功 |
|
||||||
|
| 2 | 260930235204 | 12345678 | e1Tsql1aP1SHLUthRdYI | |
|
||||||
|
| 3 | 261001001304 | 12345678 | rI48n34Ttne1qR9FDFnY | 同一时间戳多次请求 token 各异 |
|
||||||
|
| 4 | 261001001304 | 12345678 | znoWwYW1bMpwlNUzcTpX | |
|
||||||
|
| 5 | 261001001304 | 12345678 | 8Cp6IztEGvLlYt5SRjxw | |
|
||||||
|
|
||||||
|
**结论**:
|
||||||
|
- userID 固定(同一账号)
|
||||||
|
- token 每次请求生成新的 20 位临时凭据(一次性)
|
||||||
|
- 探活+登录总耗时 ~800ms(网络延迟主导)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十三、最小复刻检查清单
|
||||||
|
|
||||||
|
实现纯网络扫码登录,必须覆盖:
|
||||||
|
- [ ] 二维码输入归一化(URL → 84字符串)
|
||||||
|
- [ ] 格式校验(长度84、前缀SGWC、GameID=MAID、载荷64位hex)
|
||||||
|
- [ ] 载荷提取(`qr_string[20:]`)
|
||||||
|
- [ ] 时间戳生成(本地时间 `%y%m%d%H%M%S`)
|
||||||
|
- [ ] 签名计算(SHA256(chipID+timestamp+KEY) 大写hex)
|
||||||
|
- [ ] 请求体构造(紧凑JSON,字段顺序固定)
|
||||||
|
- [ ] HTTPS POST(无Content-Type,UA=WC_AIME_LIB,Keep-Alive)
|
||||||
|
- [ ] SSL 不验证证书
|
||||||
|
- [ ] 响应解析(errorID=0、userID>0、token非空)
|
||||||
|
- [ ] 错误处理(errorID=57→请求头问题,errorID=2→二维码过期)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本文档仅包含技术协议实现细节,不含项目结构、代码规范、文件路径等工程化内容。*
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
# 账号转生说明(`reincarnate.py`)
|
||||||
|
|
||||||
|
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里转生部分的**详细版**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、它干了什么
|
||||||
|
|
||||||
|
把一个账号(源 **A**)的数据克隆到另一个账号(目标 **B**):
|
||||||
|
|
||||||
|
```
|
||||||
|
源账号 A:二维码 -> ParseQRCode -> UserLogin -> GetNeedUserData
|
||||||
|
或:直接读 data/<A>.json(离线复用)
|
||||||
|
↓ 节点数据(19 个节点)
|
||||||
|
目标账号 B:二维码 -> ParseQRCode -> UserPreview -> UserLogin
|
||||||
|
循环:
|
||||||
|
等待 -> UpsertUserAll(一包曲目)
|
||||||
|
-> UserLogout(type=1)
|
||||||
|
-> UserLogin(isContinue=true) 续关,拿新 loginId
|
||||||
|
收尾:最后一包登出后结束(不再续关)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 源数据来自二维码(会联网抓 A 的数据,并落盘到 data/<A>.json)
|
||||||
|
python reincarnate.py --dst-qr <B的二维码> --src-qr <A的二维码>
|
||||||
|
|
||||||
|
# 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 A 的二维码)
|
||||||
|
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json
|
||||||
|
|
||||||
|
# ★ 真实上传
|
||||||
|
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --send
|
||||||
|
|
||||||
|
# 只看计划,不发包(★ 建议先跑这个)
|
||||||
|
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --plan
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--dst-qr` | 必填 | 目标账号 B 的二维码 |
|
||||||
|
| `--src-qr` / `--src-json` | 二选一 | 源数据来源 |
|
||||||
|
| `--plan` | — | 只列分批计划,不发包 |
|
||||||
|
| `--send` | 关 | ★ 真实发包 |
|
||||||
|
| `--limit N` | 0(全部) | 只迁前 N 首(试跑用) |
|
||||||
|
| `--per-packet N` | 4 | 每包曲目数,**只能是 1 / 3 / 4** |
|
||||||
|
| `--wait N` | 5.0 | 每次上传前的等待秒数(0 = 不等) |
|
||||||
|
| `--clone-identity` | 关 | 连 `userName` / `accessCode` 一起克隆(默认**保留 B 自己的**) |
|
||||||
|
| `--no-continue` | 关 | 不续关,只发一包(调试用) |
|
||||||
|
| `--debug` | 关 | 日志里带完整收发报文 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、★ 每包几首:1 / 3 / 4 可以,**恰好 2 首会被服务端拒绝**
|
||||||
|
|
||||||
|
真机一局(一个 credit)最多打 4 首,所以一条 `UpsertUserAll` 报文里的
|
||||||
|
`userPlaylogList` / `userGamePlaylogList` **最多 4 条**。
|
||||||
|
|
||||||
|
但实测:**每包恰好 2 首会被服务端拒绝**(1 / 3 / 4 首都正常)。
|
||||||
|
所以本脚本的分批器会**主动避开 2 首包**:
|
||||||
|
|
||||||
|
| 总量 | `--per-packet` | 原始切分 | 修正后 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 10 首 | 4 | `[4,4,2]` | `[4,3,3]` |
|
||||||
|
| 6 首 | 4 | `[4,2]` | `[3,3]` |
|
||||||
|
| 2 首 | 4 | `[2]` | `[1,1]` |
|
||||||
|
| 5 首 | 3 | `[3,2]` | `[3,1,1]` |
|
||||||
|
| 8 首 | 3 | `[3,3,2]` | `[3,3,1,1]` |
|
||||||
|
| 5 首 | 4 | `[4,1]` | 本来就合法,不动 |
|
||||||
|
|
||||||
|
修正规则(`reincarnate.split_batches`):
|
||||||
|
|
||||||
|
1. 前一批 **>= 4** → 从它借 1 首过来(前一批变 >=3,本批变 3)
|
||||||
|
2. 借不到(前一批只有 3,或自己就是第一批)→ 把这个 2 **拆成 1 + 1**
|
||||||
|
|
||||||
|
> ★ 只借位是不够的:前一批是 3 时借完自己变 2,等于把 2 挪了个位置。
|
||||||
|
|
||||||
|
自检里有 `1..299 首 × per∈{1,3,4}` 的全量扫描,保证永不产生 2 首包。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、★ 为什么要「续关登录」
|
||||||
|
|
||||||
|
`UpsertUserAll` 的 `playlogId` 必须等于**本次登录**的 `loginId`
|
||||||
|
(`PacketUpsertUserAll.cs:31` / `UserAllRequestVO`)。
|
||||||
|
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 —— 真机走的就是「续关」:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// source/Assembly-CSharp/Process/ContinueProcess.cs:403
|
||||||
|
new PacketUserLogin(userID, accessCode, isContinue: true, GameManager.IsEventMode ? 1 : 0, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
对应本工程:`UserLogin(user_id, token, is_continue=True)`
|
||||||
|
—— 和普通登录的唯一区别就是 `UserLoginRequestVO.isContinue = true`,
|
||||||
|
会返回**新的 `loginId` / `loginDateTime`**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、★ 登出必须用 type=1(Logout),不要用 4(TestIn)
|
||||||
|
|
||||||
|
`Net.VO.Mai2/LogoutType.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum LogoutType { None=0, Logout=1, Cancel=2, Error=3, TestIn=4, Quit=5 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**真机抓包实证** `UserLogoutApi` 用的是 **type=1**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"userId":12570152,"accessCode":"","regionId":8,"placeId":1007,
|
||||||
|
"clientId":"A63E01C3047","loginDateTime":1791325354,"type":1,"delayLog":{...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
源码侧的正常会话结束也是 type=1:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// source/Assembly-CSharp/Process.UserDataNet.State.UserDataULState/StateULUserLogout.cs:24
|
||||||
|
PacketHelper.StartPacket(new PacketUserLogout(_index, _userId, LogoutType.Logout, ...));
|
||||||
|
```
|
||||||
|
|
||||||
|
**续关场景下用 4(TestIn)会出问题** —— 那是「测试登入 / 顶号」的语义
|
||||||
|
(`GameMain.cs:104` 退出游戏时才用)。
|
||||||
|
|
||||||
|
`UserLogout` 对 type=1 会自动带上完整 `delayLog`
|
||||||
|
(`PacketUserLogout.cs:21`:`logoutType == LogoutType.Logout` 时才收集会话统计)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、playlog 从哪来
|
||||||
|
|
||||||
|
`GetNeedUserData` 抓不到 `userPlaylogList`(那是逐局记录,没有对应的 Get 接口),
|
||||||
|
所以本脚本**从曲目记录反推合成**:
|
||||||
|
|
||||||
|
| `UserMusicDetail`(9 字段) | → | `UserPlaylog`(110 字段) |
|
||||||
|
|---|---|---|
|
||||||
|
| `musicId` / `level` | | `musicId` / `level` |
|
||||||
|
| `achievement` | | `achievement` |
|
||||||
|
| `deluxscoreMax` | | `deluxscore` |
|
||||||
|
| `scoreRank` | | `scoreRank` / `isClear` |
|
||||||
|
| `comboStatus` / `syncStatus` | | 同名 |
|
||||||
|
| 其余 | | `config.PLAY_*` 默认值 |
|
||||||
|
|
||||||
|
`UserMusicDetail` 的全部字段(`Net.VO.Mai2/UserMusicDetail.cs`):
|
||||||
|
`musicId` / `level` / `playCount` / `achievement` / `comboStatus` /
|
||||||
|
`syncStatus` / `deluxscoreMax` / `scoreRank` / `extNum1`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、迁移范围
|
||||||
|
|
||||||
|
默认迁**全部节点**(整账号克隆):
|
||||||
|
|
||||||
|
- **第 1 包**带全部非曲目节点(`userData` / `userExtend` / `userOption` /
|
||||||
|
`userCharacterList` / `userMapList` / …)
|
||||||
|
- **之后每包**只带这一批曲目 + 合成的 `userGamePlaylogList`
|
||||||
|
- 位串 `isNew*` 按 **B 的基线**逐条算(B 没有 → `"1"`,已有 → `"0"`),
|
||||||
|
主键表见 `api/upsert_user_all.py` 的 `DIFF_PRIMARY_KEYS`
|
||||||
|
|
||||||
|
**不迁的节点**(`SKIP_NODES`):
|
||||||
|
|
||||||
|
| 节点 | 原因 |
|
||||||
|
|---|---|
|
||||||
|
| `userMusicDetailList` | 单独分批 |
|
||||||
|
| `userGamePlaylogList` | 本脚本按曲目合成 |
|
||||||
|
| `user2pPlaylog` | 2P 记录,迁移无意义 |
|
||||||
|
| `userGetPointList` | 里程/奖励,迁过去会凭空发点 |
|
||||||
|
| `userTradeItemList` | 无 Get 来源 |
|
||||||
|
| `userFriendSeasonRankingList` | 无 Get 来源 |
|
||||||
|
|
||||||
|
**默认不克隆 `userName` / `accessCode`**(避免把 B 改名);要连身份一起搬就加
|
||||||
|
`--clone-identity`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **未实跑过。** 建议先用 `--plan` 看计划,再 `--limit 8` 小批量试,确认无误再全量。
|
||||||
|
2. **会真实写入账号 B(不可撤销)。** 不传 `--send` 时只构造报文。
|
||||||
|
3. **耗时**:曲目多的话包数很多(每包最多 4 首),加上 `--wait` 的等待,
|
||||||
|
几百首会跑很久。想快可以调小 `--wait`。
|
||||||
|
4. **二维码有效期 10 分钟**,但转生过程中只用一次(登录时)——
|
||||||
|
续关用的是 token,不需要新二维码。
|
||||||
|
5. 基线快照只在**第一次登录后抓一次**,后续包沿用同一份做差分。
|
||||||
|
曲目节点由 `extra_data` 整体覆盖,位串也单独算,不受影响。
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
# 购买功能票 —— 存档与交接文档
|
||||||
|
|
||||||
|
> **状态:暂停(2026-10-06)**
|
||||||
|
> 暂停原因:多次失败购买后主账号疑似被服务端拉黑;实现仍未跑通。
|
||||||
|
> 本文把「从开始做买票 → 到暂停」之间的**全部有用信息**归档,
|
||||||
|
> 目的是**将来恢复这项工作时能 5 分钟上手**,不用重新踩一遍。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 〇、30 秒速览
|
||||||
|
|
||||||
|
1. **协议已完全搞清楚** —— 源码逐行 + 真机抓包 328 字节原文都有
|
||||||
|
2. **报文已做到与真机逐字节一致**,但服务端仍回 `returnCode=0`
|
||||||
|
3. **同一账号对照实验证明:机台能买、脚本不能买** → 差异在**客户端**,不是账号/服务端
|
||||||
|
4. 差异最可能在 **HTTP 层**(请求头 / Cookie / 连接特征),**不在报文体**
|
||||||
|
5. 暂停前最后动作:给 MelonLoader Mod 加了「完整请求头 + 响应头 dump」,**等一次真机抓包**
|
||||||
|
6. ⚠️ **恢复工作前先确认账号没被拉黑**(见第八节)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、结论速览
|
||||||
|
|
||||||
|
| 项 | 状态 |
|
||||||
|
|---|---|
|
||||||
|
| 接口名 / URL 混淆 | ✅ 已确认 |
|
||||||
|
| 请求体结构(4 顶层 + 8 + 4 字段) | ✅ 与真机逐字节一致 |
|
||||||
|
| `purchaseDate` 的 `.f` 小数位 | ✅ 已确认并修正 |
|
||||||
|
| `validDate` 算法 | ✅ 已确认并双向验证 |
|
||||||
|
| `price` 语义(= `ticket.creditNum`) | ✅ 已确认 |
|
||||||
|
| `stock` 语义(购买后的值) | ✅ 已确认 |
|
||||||
|
| 键集合 / 字段顺序 | ✅ 与真机一致 |
|
||||||
|
| 可购买清单校验 | ✅ 已实现(真机也有这一步) |
|
||||||
|
| 已持票守卫 | ✅ 已实现(**自加的安全措施**,真机没有) |
|
||||||
|
| **服务端接受** | ❌ **`returnCode=0`** |
|
||||||
|
| **根因** | ❓ **未定位**,怀疑 HTTP 层 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、协议事实(全部有源码依据)
|
||||||
|
|
||||||
|
### 2.1 接口
|
||||||
|
|
||||||
|
| 项目 | 值 | 依据 |
|
||||||
|
|---|---|---|
|
||||||
|
| 逻辑 API 名 | `UpsertUserChargelogApi` | `Net.Packet.Mai2/PacketUploadUserChargelog.cs:19` |
|
||||||
|
| URL 尾段 | `md5("UpsertUserChargelogApiMaimaiChn" + "8bF76dE9")` = `4411a4c8f7f5c0e95a3ac17f1746aad3` | `Packet.cs:485` + `NetQuery.cs:14` |
|
||||||
|
| 请求 VO | `Net.VO.Mai2/UserChargelogRequestVO.cs` | — |
|
||||||
|
| 响应 | `{returnCode, apiName}` | — |
|
||||||
|
| 副作用 | ★ 有,写入购买记录 | — |
|
||||||
|
|
||||||
|
### 2.2 请求体(字段顺序照 C# 声明)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"userId": <ulong>,
|
||||||
|
"userChargelog": { // UserChargelog.cs,8 字段
|
||||||
|
"chargeId": 2,
|
||||||
|
"price": 1, // ★ = ticket.creditNum,不是投币数
|
||||||
|
"purchaseDate": "2026-10-06 13:20:10.0", // ★ 带 .f 一位小数
|
||||||
|
"playCount": 1262,
|
||||||
|
"playerRating": 15000,
|
||||||
|
"placeId": 1000,
|
||||||
|
"regionId": 5,
|
||||||
|
"clientId": "TESTCLIENT1"
|
||||||
|
},
|
||||||
|
"userCharge": { // UserCharge.cs,4 字段
|
||||||
|
"chargeId": 2,
|
||||||
|
"stock": 1, // ★ 购买后的库存(= 原值 + 1)
|
||||||
|
"purchaseDate": "2026-10-06 13:20:10.0",
|
||||||
|
"validDate": "2027-01-04 04:00:00" // ★ 不带小数
|
||||||
|
},
|
||||||
|
"loginDateTime": 1791159576 // ★ long,来自 UserLoginApi
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 两个容易踩的格式
|
||||||
|
|
||||||
|
| 字段 | 格式 | 例子 |
|
||||||
|
|---|---|---|
|
||||||
|
| `purchaseDate` | `TimeManager.DateTimeFormat` = `yyyy-MM-dd HH:mm:ss.f` | `2026-10-06 13:20:10.0` |
|
||||||
|
| `validDate` | 显式串 `yyyy-MM-dd 04:00:00` | `2027-01-04 04:00:00` |
|
||||||
|
|
||||||
|
> **一个带小数一个不带** —— 少写 `.0` 服务端直接 `returnCode=0`(踩过)。
|
||||||
|
|
||||||
|
### 2.4 `validDate` 算法
|
||||||
|
|
||||||
|
`Manager/TicketManager.cs:562-565`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||||||
|
```
|
||||||
|
|
||||||
|
真机双向验证:
|
||||||
|
|
||||||
|
| chargeId | 真机 purchaseDate | 减 4h + 90d | 真机 validDate | 吻合 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 5 | 2026-10-04 18:22:10 | 2027-01-02 | `2027-01-02 04:00:00` | ✅ |
|
||||||
|
| 3 | 2026-10-02 10:49:23 | 2026-12-31 | `2026-12-31 04:00:00` | ✅ |
|
||||||
|
|
||||||
|
### 2.5 `price` = `ticket.creditNum`
|
||||||
|
|
||||||
|
`Net.VO.Mai2/VOExtensions.cs:410`:`result.price = ticket.creditNum;`
|
||||||
|
|
||||||
|
数据来自游戏本地主数据 `Sinmai_Data/StreamingAssets/A000/ticket/ticketXXXXXX/Ticket.xml`
|
||||||
|
(已抄进 `config.TICKET_CREDIT_NUM`):
|
||||||
|
|
||||||
|
| chargeId | 票名 | `creditNum`(上报的 price) | 机台投币数 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2 | 2倍チケット | **1** | 1 |
|
||||||
|
| 3 | 3倍チケット | **2** | 2 |
|
||||||
|
| 4 | 4倍チケット | 3 | — |
|
||||||
|
| 5 | 5倍チケット | 4 | — |
|
||||||
|
| 6 | 6倍チケット | 5 | — |
|
||||||
|
|
||||||
|
### 2.6 真机的完整流程
|
||||||
|
|
||||||
|
```
|
||||||
|
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
|
||||||
|
├─ ticketKind.id == 1(付费票)
|
||||||
|
├─ :137-150 在 GetGameChargeDataList() 里找该 chargeId
|
||||||
|
│ ★ 找不到直接 return false,不发请求
|
||||||
|
├─ :207 SetBuyPaidTicketData -> stock++、更新 purchaseDate / validDate
|
||||||
|
└─ :234 RequestUserCharge
|
||||||
|
├─ VOExtensions.cs:436 ExportEnableUserCharge -> 购买后的 UserCharge
|
||||||
|
├─ VOExtensions.cs:403 ExportUserChargelog -> price = ticket.creditNum
|
||||||
|
└─ 发 UpsertUserChargelogApi
|
||||||
|
```
|
||||||
|
|
||||||
|
成功后 `TicketManager.cs:255 RequestSuccess` 会**本地** `stock--`(票被本局用掉),
|
||||||
|
但这个减一**不会发给服务器** —— 所以服务端会一直保留 `stock=1`,
|
||||||
|
直到下一次结算的 `UpsertUserAll` 把新的 `userChargeList` 写回去。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、真机抓包原文(★ 成功样本)
|
||||||
|
|
||||||
|
来源:`MaimaiNetSpy/raw/061_UpsertUserChargelogApiMaimaiChn_request.json`(328 字节)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"userId":12345678,"userChargelog":{"chargeId":2,"price":1,"purchaseDate":"2026-10-06 13:20:10.0","playCount":1262,"playerRating":15000,"placeId":1000,"regionId":5,"clientId":"TESTCLIENT1"},"userCharge":{"chargeId":2,"stock":1,"purchaseDate":"2026-10-06 13:20:10.0","validDate":"2027-01-04 04:00:00"},"loginDateTime":1791159576}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"returnCode":1,"apiName":"com.sega.maimai.api.UpsertUserChargelogApi"}
|
||||||
|
```
|
||||||
|
|
||||||
|
请求前后的时序(同一会话):
|
||||||
|
|
||||||
|
```
|
||||||
|
[13] 13:19:45 UserLoginApi
|
||||||
|
[14]~[60] 入场全量数据下载(47 个请求)
|
||||||
|
[61] 13:20:10 UpsertUserChargelogApi ← 成功
|
||||||
|
[62] 13:20:46 GetUserNewItemListApi
|
||||||
|
[63] 13:20:53 UserLogoutApi
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、我的实现现状
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `api/upsert_user_chargelog.py` | 买票 API。报文构造 + 三道守卫 + 闸门 |
|
||||||
|
| `buy_ticket.py` | 主脚本:扫码 → 登录 → 入场下载 → 查票 → 等待 → 买票 → 查票 → 登出 |
|
||||||
|
|
||||||
|
### 4.1 `UpsertUserChargelog` 参数
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `user_id` / `charge_id` / `login_date_time` | 必填 | 后两个必须与 `UserLoginApi` 一致 |
|
||||||
|
| `charge_list` | 自动抓 | 当前票券(`GetUserChargeApi`) |
|
||||||
|
| `game_charge_list` | 自动抓 | 可购买清单(`GetGameChargeApi`) |
|
||||||
|
| `user_data` | 自动抓 | 取 `playCount` / `playerRating` |
|
||||||
|
| `stock` | 现有 + 1 | 购买后库存 |
|
||||||
|
| `price` | `TICKET_CREDIT_NUM[charge_id]` | = `creditNum` |
|
||||||
|
| `purchase_date` / `valid_date` | 现在 / 由前者推 | |
|
||||||
|
| `guard_purchasable` | `True` | 不在可购买清单里就拒绝 |
|
||||||
|
| `guard_no_stock` | `True` | ★ **已持票就拒绝**(自加,真机没有) |
|
||||||
|
| `confirm` | `config.UPSERT_CHARGELOG_CONFIRM` | 不开不发包 |
|
||||||
|
|
||||||
|
### 4.2 已验证「正确」的东西
|
||||||
|
|
||||||
|
| 项 | 证据 |
|
||||||
|
|---|---|
|
||||||
|
| URL / 混淆名 | `4411a4c8f7f5c0e95a3ac17f1746aad3` = 真机 |
|
||||||
|
| User-Agent | 逐字符相同 |
|
||||||
|
| 键集合 / 顺序 / 类型 | 与真机逐字节同构(328 B vs 328 B) |
|
||||||
|
| `.f` 格式 | `purchaseDate` 已带一位小数 |
|
||||||
|
| Cookie | 登录响应确实下发 `Set-Cookie: JSESSIONID=...`,Session 自动带 |
|
||||||
|
| `number` 头 | 已调 `Ping()`(真机 75,我这边 47~61) |
|
||||||
|
| 入场数据下载 | 已补(18~19 节点) |
|
||||||
|
| 节奏 | 已加 30 秒等待(真机是 25 秒) |
|
||||||
|
|
||||||
|
### 4.3 我发出的实际请求头(离线检视)
|
||||||
|
|
||||||
|
```
|
||||||
|
User-Agent = '4411a4c8f7f5c0e95a3ac17f1746aad3#<userId>'
|
||||||
|
Accept-Encoding = '' ← 空串(.NET 不发这个头)
|
||||||
|
Accept = '*/*' ← requests 自带
|
||||||
|
Connection = 'keep-alive'
|
||||||
|
Content-Type = 'application/json'
|
||||||
|
charset = 'UTF-8'
|
||||||
|
Mai-Encoding = '1.55'
|
||||||
|
Content-Encoding = 'deflate'
|
||||||
|
number = '61'
|
||||||
|
Cookie = 'JSESSIONID=...'
|
||||||
|
Content-Length = '32'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、完整实验时间线(全部失败)
|
||||||
|
|
||||||
|
| # | 时间 | 账号 | chargeId | 关键改动 | 结果 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 13:10 | A | 2 | `purchaseDate` **少 `.0`** | `returnCode=0` |
|
||||||
|
| 2 | 13:41 | A | 5 | 格式已修 | `returnCode=0`(★ 5 号票**未上架**) |
|
||||||
|
| 3 | 13:50 | A | 3 | 换了上架的票 | `returnCode=0` |
|
||||||
|
| 4 | 13:53 | A | 3 | + 入场全量下载 | `returnCode=0` |
|
||||||
|
| 5 | 14:03 | A | 2 | + 5 秒等待 | `returnCode=0` |
|
||||||
|
| 6 | 15:51 | A | 3 | + `Ping()`、30 秒等待 | `returnCode=0` |
|
||||||
|
| 7 | 16:33 | **B** | 2 | 换了个**没被黑的号** | 🛡️ 被守卫拦下(B 已持票) |
|
||||||
|
| 8 | **16:39** | **B** | 2 | B 的票已用掉,守卫放行 | ❌ **`returnCode=0`** |
|
||||||
|
|
||||||
|
### ★ 第 8 次是决定性实验
|
||||||
|
|
||||||
|
| | 机台 | 我的脚本 |
|
||||||
|
|---|---|---|
|
||||||
|
| 账号 A | ✅ 13:20、14:06 成功 | ❌ 6 次失败 |
|
||||||
|
| **账号 B** | ✅ **16:31:51 成功** | ❌ **16:39:37 失败** |
|
||||||
|
|
||||||
|
**同一个账号 B:机台能买、脚本不能买 → 差异在客户端,不在账号/服务端。**
|
||||||
|
|
||||||
|
> 此前一直以为是「账号被拉黑」,第 8 次推翻了它。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、已排除的假设
|
||||||
|
|
||||||
|
| # | 假设 | 排除依据 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | URL / 混淆名错 | 服务端回正确 `apiName` |
|
||||||
|
| 2 | User-Agent 错 | 与真机逐字符相同 |
|
||||||
|
| 3 | Cookie 缺失 | 登录响应确有 `Set-Cookie: JSESSIONID` |
|
||||||
|
| 4 | 报文字段 / 顺序 / 类型错 | 与真机逐字节同构 |
|
||||||
|
| 5 | `purchaseDate` 格式错 | 已补 `.f`(第 1 次之后) |
|
||||||
|
| 6 | 票未上架 | 第 3 次起换成清单里的票 |
|
||||||
|
| 7 | 已持有功能票 | 第 8 次确认持有 0 张 |
|
||||||
|
| 8 | `number` 头为 0 | 第 6 次起已 `Ping()` |
|
||||||
|
| 9 | 缺少入场数据下载 | 第 4 次起已补 |
|
||||||
|
| 10 | 节奏太快 | 第 6 次起 30 秒等待 |
|
||||||
|
| 11 | `chargeId` 本身有问题 | 换用真机验证过的 2 号票仍失败 |
|
||||||
|
| 12 | 账号被拉黑 | ★ **第 8 次用没被黑的号 B 仍然失败** |
|
||||||
|
|
||||||
|
### 剩余假设
|
||||||
|
|
||||||
|
| # | 假设 | 怎么验证 |
|
||||||
|
|---|---|---|
|
||||||
|
| **A** | ★ **HTTP 请求头 / 连接特征有差异** | 抓真机完整请求头逐字节对比(mod 已就绪) |
|
||||||
|
| B | 服务端对金钱接口做了 TLS 指纹校验 | 需要 MITM 代理 |
|
||||||
|
| C | 需要某个本工程不知道的前置状态 | 同上 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、★ 别人的可用实现(截图转录)
|
||||||
|
|
||||||
|
> 图片主人说这套代码**能成功买票**,流程为「登录 → userdata → ticket → logout」。
|
||||||
|
|
||||||
|
### 7.1 报文构造
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
import pytz
|
||||||
|
from .sdgb import sdgb_api
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
def get_ticket(userId: int, cookie, chargeId, price, regionId, placeId,
|
||||||
|
clientId, playCount: int, playerRating: int):
|
||||||
|
now = (datetime.now(pytz.timezone('Asia/Shanghai'))).strftime("%Y-%m-%d %H:%M:%S.0")
|
||||||
|
validDate = (datetime.now(pytz.timezone('Asia/Shanghai')) + timedelta(days=90)) \
|
||||||
|
.replace(hour=4, minute=0, second=0).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
data = json.dumps({
|
||||||
|
"userId": userId,
|
||||||
|
"userCharge": {
|
||||||
|
"chargeId": chargeId,
|
||||||
|
"stock": 1,
|
||||||
|
"purchaseDate": now,
|
||||||
|
"validDate": validDate
|
||||||
|
},
|
||||||
|
"userChargelog": {
|
||||||
|
"chargeId": chargeId,
|
||||||
|
"price": price,
|
||||||
|
"purchaseDate": now,
|
||||||
|
"placeId": placeId,
|
||||||
|
"regionId": regionId,
|
||||||
|
"clientId": clientId,
|
||||||
|
"playCount": playCount,
|
||||||
|
"playerRating": playerRating,
|
||||||
|
},
|
||||||
|
"loginDate": now, # ★★ 注意:是 loginDate(字符串),不是 loginDateTime
|
||||||
|
})
|
||||||
|
|
||||||
|
ticket_result = sdgb_api(data, "UpsertUserChargelogApi", cookie)
|
||||||
|
return ticket_result
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 调用方流程
|
||||||
|
|
||||||
|
```python
|
||||||
|
if ticket_id in ticket_list:
|
||||||
|
login_time = login_datetime_now()
|
||||||
|
login_result = user_login_checked(user_id, token, region_id, place_id, client_id, login_time)
|
||||||
|
if login_result['Result']['returnCode'] == 1:
|
||||||
|
login_cookie = login_result['Cookie'] # ★ 显式取出 cookie
|
||||||
|
logger.info(f'{user_id} get_ticket: 获取到 Cookie = {login_cookie}')
|
||||||
|
|
||||||
|
match ticket_id: # ★ price 表
|
||||||
|
case 2: ticket_price = 1
|
||||||
|
case 3: ticket_price = 2
|
||||||
|
case 4: ticket_price = 3
|
||||||
|
case 5: ticket_price = 4
|
||||||
|
case 6: ticket_price = 5
|
||||||
|
case _: ticket_price = 0
|
||||||
|
|
||||||
|
userdata_result = user_datas(user_id, login_cookie) # ★ cookie 显式传
|
||||||
|
play_count = userdata_result["userData"]["playCount"]
|
||||||
|
player_rating = userdata_result["userData"]["playerRating"]
|
||||||
|
|
||||||
|
# ★★ 获取 userdata 后等待 60 秒再请求 ticket
|
||||||
|
logger.info(f'{user_id} get_ticket: 等待 60 秒后请求 ticket')
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
ticket_result = api_get_ticket(
|
||||||
|
user_id, login_cookie, ticket_id, ticket_price,
|
||||||
|
region_id, place_id, client_id, play_count, player_rating
|
||||||
|
)
|
||||||
|
logger.debug(f'ticket_result 完整内容 = {json.dumps(ticket_result, ensure_ascii=False, indent=2)}')
|
||||||
|
|
||||||
|
if ticket_result and ticket_result.get('returnCode') == 1:
|
||||||
|
logout_result = user_logout(user_id, login_cookie, region_id, place_id, client_id, 0, 4)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 ★ 与我实现的差异对照
|
||||||
|
|
||||||
|
| # | 他们 | 我 | 重要性 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **1** | **`"loginDate": "2026-10-06 16:39:37.0"`**(字符串) | `"loginDateTime": 1791304745`(数字) | ★★★ **最大疑点** |
|
||||||
|
| 2 | `stock` 硬编码 `1` | 现有 + 1 | 低(结果一样) |
|
||||||
|
| 3 | 等待 **60 秒** | 30 秒 | 中 |
|
||||||
|
| 4 | `validDate` = `(now+90d).replace(hour=4)` | `(now-4h+90d).date() + " 04:00:00"` | 低(源码支持我的) |
|
||||||
|
| 5 | `purchaseDate` 的 `.0` 写死 | 取真实十分位 | 低 |
|
||||||
|
| 6 | **Cookie 显式传递** | `requests.Session` 自动 | ★★ 中 |
|
||||||
|
| 7 | 无 `GetGameChargeApi` 前置 | 有 | 低 |
|
||||||
|
| 8 | 无「已持票」守卫 | 有(自加) | 低(安全相关) |
|
||||||
|
| 9 | 流程:登录→userdata→ticket→logout | 登录→入场下载→查票→等待→ticket→查票→logout | ★ 中 |
|
||||||
|
| 10 | `userCharge` 在 `userChargelog` 之前 | 反之 | 低(JSON 无序) |
|
||||||
|
| 11 | `userChargelog` 内部顺序也不同 | 照 C# 声明 | 低 |
|
||||||
|
| 12 | `sdgb_api(data, "UpsertUserChargelogApi", cookie)` | `request_dict(...)` | ★★ **黑盒,未知** |
|
||||||
|
|
||||||
|
### 7.4 ★ 我的疑惑(恢复工作时优先解答)
|
||||||
|
|
||||||
|
1. **`loginDate` vs `loginDateTime`(最重要)**
|
||||||
|
- 源码 `UserChargelogRequestVO.cs` 声明的是 `public long loginDateTime;`
|
||||||
|
- 真机抓包发的是 `"loginDateTime":1791159576`(数字)
|
||||||
|
- **他们发的是 `"loginDate"`(字符串)**,而且说能成功
|
||||||
|
- 三种可能:
|
||||||
|
a. 服务端**不校验**这个字段 → 那我的 `loginDateTime` 也不是问题
|
||||||
|
b. 服务端认的是 `loginDate`(旧字段名)
|
||||||
|
c. 他们的 `sdgb_api` 内部做了改名(把 `loginDate` → `loginDateTime`)
|
||||||
|
- **必须先搞清楚这一条**,否则会一直在错的方向排查
|
||||||
|
|
||||||
|
2. **`sdgb_api(data, apiName, cookie)` 到底做了什么?**(最大的黑盒)
|
||||||
|
- 是否补 `MaimaiChn` 后缀?是否做 md5 混淆?
|
||||||
|
- 请求头怎么设的?(`Accept-Encoding`?`number`?`Mai-Encoding`?`Content-Type`?)
|
||||||
|
- **cookie 是怎么放进请求的**(header 直写?cookie jar?)
|
||||||
|
- 加解密实现是否一致
|
||||||
|
- ★ **他们的成功很可能就藏在这里**
|
||||||
|
|
||||||
|
3. **Cookie 显式传递的含义**
|
||||||
|
- 他们从登录结果里取出 cookie,手工传给每个请求
|
||||||
|
- 这个 cookie 是 `Set-Cookie` 原样,还是提取出的 `JSESSIONID=...`?
|
||||||
|
- 和 `requests.Session` 自动管理的**有没有不同**?
|
||||||
|
(比如:他们可能把**整个 Set-Cookie 串**塞进 `Cookie:` 头)
|
||||||
|
|
||||||
|
4. **60 秒等待是必需的吗?**
|
||||||
|
- 真机是 25 秒,他们用 60 秒 —— 是协议要求还是保险起见?
|
||||||
|
|
||||||
|
5. **`stock` 硬编码 1**
|
||||||
|
- 如果账号已有该票(`stock=1`),他们还是发 1
|
||||||
|
- 那「已持票不能买」的规则到底存不存在?(我加守卫是基于用户的警告)
|
||||||
|
|
||||||
|
6. **`validDate` 在凌晨会差一天**
|
||||||
|
- 他们的算法在 00:00~04:00 之间会比我多算一天
|
||||||
|
- 谁对?(源码支持我的,但他们的能跑通)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、⚠️ 账号状态与风险
|
||||||
|
|
||||||
|
| 账号 | 状态 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| **A**(主号) | ⚠️ **疑似被拉黑** | 6 次失败购买后,机台与脚本都买不了 |
|
||||||
|
| **B**(测试号) | 暂未见异常 | 机台成功过;脚本失败 1 次 |
|
||||||
|
|
||||||
|
**风险提示:**
|
||||||
|
|
||||||
|
1. **不要反复重试买票** —— 每次都是一次真实的服务端交互,**可能正是失败次数导致拉黑**
|
||||||
|
2. 恢复工作前**先用机台确认账号还能不能买**
|
||||||
|
3. 优先用**非主力账号**做实验
|
||||||
|
4. 已有的三道守卫(可购买清单 / 已持票 / 副作用闸门)**不要为了「试通」而关掉**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、恢复工作时怎么做
|
||||||
|
|
||||||
|
### 9.1 先读这些
|
||||||
|
|
||||||
|
| 文件 | 看什么 |
|
||||||
|
|---|---|
|
||||||
|
| 本文 | 全部背景 |
|
||||||
|
| `api/upsert_user_chargelog.py` | 实现与守卫 |
|
||||||
|
| `buy_ticket.py` | 主脚本流程 |
|
||||||
|
| `source/Assembly-CSharp/Net.Packet.Mai2/PacketUploadUserChargelog.cs` | 接口定义 |
|
||||||
|
| `source/Assembly-CSharp/Manager/TicketManager.cs:130-270` | 真机购买流程 |
|
||||||
|
| `source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:403-451` | 三个 Export 函数 |
|
||||||
|
|
||||||
|
### 9.2 第一件事:抓真机完整 HTTP 头
|
||||||
|
|
||||||
|
**MelonLoader Mod 已经改好了**(`gamehook/Main.cs`),现在会 dump:
|
||||||
|
|
||||||
|
- `★ 真实 HTTP 请求头(HttpWebRequest,含框架自带)` —— Method / ContentType / UserAgent /
|
||||||
|
ContentLength / KeepAlive / Expect / AutomaticDecompression / **Headers.AllKeys** / **Cookie 头**
|
||||||
|
- `★ 真实 HTTP 响应头` —— StatusCode / 全部响应头(含 **`Set-Cookie: JSESSIONID`**)
|
||||||
|
|
||||||
|
构建与部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd gamehook && dotnet build -c Release
|
||||||
|
copy bin\Release\MaimaiNetSpy.dll "D:\SDGB2026\Package\Mods\MaimaiNetSpy.dll"
|
||||||
|
```
|
||||||
|
|
||||||
|
然后**在机台上用没被黑的账号买一张票**,把日志发回来:
|
||||||
|
|
||||||
|
```
|
||||||
|
D:\SDGB2026\Package\MaimaiNetSpy\YYYY-MM-DD_HHMMSS.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 拿到日志后怎么比
|
||||||
|
|
||||||
|
1. 找 `UpsertUserChargelogApi` 那一段
|
||||||
|
2. 对比**请求头集合**:多了什么?少了什么?
|
||||||
|
- 重点:`Expect`、`Accept-Encoding`、`Cookie`、`Connection`、以及**头的顺序**
|
||||||
|
3. 对比 `Cookie` 头:真机发的是 `JSESSIONID=...` 还是别的?
|
||||||
|
4. 对比登录响应的 `Set-Cookie`:和我 Session 里存的是不是同一个
|
||||||
|
5. 如果请求头完全一致 → 转向 TLS 指纹 / MITM 抓完整 TCP 层
|
||||||
|
|
||||||
|
### 9.4 验证买票是否成功的命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 预检(不发包)
|
||||||
|
python buy_ticket.py "<二维码>" --charge-id 2
|
||||||
|
|
||||||
|
# 真实购买(★ 有副作用)
|
||||||
|
python buy_ticket.py "<二维码>" --charge-id 2 --send
|
||||||
|
|
||||||
|
# 可选参数
|
||||||
|
--wait-before 60 # 默认 30 秒
|
||||||
|
--skip-entry # 跳过入场全量下载
|
||||||
|
--debug # 含完整收发报文
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.5 一个可能的快速验证
|
||||||
|
|
||||||
|
如果第 7.4 节的疑惑 1(`loginDate` vs `loginDateTime`)成立,
|
||||||
|
那么最快的验证是:**在 `extra_data` 里把 `loginDateTime` 换掉**——
|
||||||
|
|
||||||
|
但目前 `extra_data` 只作用于 `upsertUserAll` 的 36 个节点,
|
||||||
|
**顶层字段(`userId` / `userChargelog` / `userCharge` / `loginDateTime`)改不了**,
|
||||||
|
需要临时改 `upsert_user_chargelog.py` 的 `UserChargelogRequestVO` 定义。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、代码位置索引
|
||||||
|
|
||||||
|
| 位置 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `api/upsert_user_chargelog.py` | `UpsertUserChargelog` / `ChargelogConfirmError` / `ChargelogGuardError` / `calc_valid_date` / `parse_wall_time` / `now_cst` |
|
||||||
|
| `buy_ticket.py` | 主脚本 + `show_charges()`(跑前跑后查票) |
|
||||||
|
| `config.py` | `TICKET_CREDIT_NUM` / `TICKET_NAMES` / `PAID_TICKET_IDS` / `UPSERT_CHARGELOG_CONFIRM` / `TICKET_VALID_DAYS` / `TICKET_VALID_HOUR_OFFSET` |
|
||||||
|
| `selftest.py` | 13.6 节:买票相关的 20+ 项自检(含**与真机报文逐字节回归**) |
|
||||||
|
| `gamehook/Main.cs` | `PostRequestDumpHeaders` / `PostGetResponse`(响应头) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本文写于 2026-10-06,买票功能暂停时。*
|
||||||
+477
@@ -0,0 +1,477 @@
|
|||||||
|
# 购买功能票流程(登录 → 买票 → 登出)
|
||||||
|
|
||||||
|
> 对应 `api/upsert_user_chargelog.py` 与主脚本范例 `buy_ticket.py`。
|
||||||
|
> **判据 = `source/Assembly-CSharp/` 反编译源码 + 游戏本地主数据 Ticket.xml。**
|
||||||
|
>
|
||||||
|
> ⚠ 本文的实现**全部来自源码推导**,还**没有真机 NetworkLog 对照**。
|
||||||
|
> 文末列出「需要一次抓包确认」的点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、一句话
|
||||||
|
|
||||||
|
在机台买一张功能票 = 往服务器发一个 `UpsertUserChargelogApi`,
|
||||||
|
把「买了哪张票、多少钱、什么时候买的、有效期到什么时候、买完还剩几张」写进去。
|
||||||
|
|
||||||
|
```
|
||||||
|
ParseQRCode -> UserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、接口
|
||||||
|
|
||||||
|
| 项目 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 逻辑 API 名 | `UpsertUserChargelogApi` |
|
||||||
|
| 源码 | `Net.Packet.Mai2/PacketUploadUserChargelog.cs:19` |
|
||||||
|
| 请求 VO | `Net.VO.Mai2/UserChargelogRequestVO.cs`(4 字段) |
|
||||||
|
| 响应 | `{returnCode, apiName}` |
|
||||||
|
| 副作用 | ★ 有,会写入一条购买记录 |
|
||||||
|
|
||||||
|
### 2.1 请求体(字段顺序照 C# 声明)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"userId": 12345678,
|
||||||
|
"userChargelog": { // ← UserChargelog.cs,8 字段
|
||||||
|
"chargeId": 2, // 买的哪张票
|
||||||
|
"price": 1, // ★ = ticket.creditNum,不是 money price
|
||||||
|
"purchaseDate": "2026-10-06 13:01:22",
|
||||||
|
"playCount": 1260, // 玩家总游玩次数
|
||||||
|
"playerRating": 15000, // 玩家 Rating
|
||||||
|
"placeId": 1000,
|
||||||
|
"regionId": 1,
|
||||||
|
"clientId": "TESTCLIENT1"
|
||||||
|
},
|
||||||
|
"userCharge": { // ← UserCharge.cs,4 字段
|
||||||
|
"chargeId": 2,
|
||||||
|
"stock": 1, // ★ 购买后的库存
|
||||||
|
"purchaseDate": "2026-10-06 13:01:22",
|
||||||
|
"validDate": "2027-01-04 04:00:00"
|
||||||
|
},
|
||||||
|
"loginDateTime": 1791291143
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、源码流程(逐行可查)
|
||||||
|
|
||||||
|
```
|
||||||
|
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
|
||||||
|
│
|
||||||
|
├─ ticket.ticketKind.id == 1 ? ← 只有 Paid 票才走购买
|
||||||
|
│ (ticketKind: 1=Paid 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara)
|
||||||
|
│
|
||||||
|
├─ 在 OperationManager.GetGameChargeDataList() 里找该 chargeId 的 price
|
||||||
|
│ (= GetGameChargeApi 的响应,含 orderId/chargeId/price/startDate/endDate)
|
||||||
|
│
|
||||||
|
├─ TicketManager.cs:207 SetBuyPaidTicketData(playerId, ticketId)
|
||||||
|
│ ├─ ChargeList 里已有该 chargeId -> stock++、更新 purchaseDate / validDate
|
||||||
|
│ └─ 没有 -> 新增一条 stock=1
|
||||||
|
│
|
||||||
|
└─ TicketManager.cs:234 RequestUserCharge(playerId)
|
||||||
|
├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId)
|
||||||
|
│ -> 取**购买后**(stock > 0)的那条 UserCharge
|
||||||
|
│
|
||||||
|
├─ VOExtensions.cs:403 ExportUserChargelog(ticketId)
|
||||||
|
│ chargeId = ticketId
|
||||||
|
│ price = DataManager.GetTicket(ticketId).creditNum ★
|
||||||
|
│ purchaseDate = TimeManager.GetNowDateString()
|
||||||
|
│ playCount = src.Detail.PlayCount
|
||||||
|
│ playerRating = (int)src.Detail.Rating
|
||||||
|
│ placeId = Auth.LocationId
|
||||||
|
│ regionId = Auth.RegionCode
|
||||||
|
│ clientId = AMDaemon.System.KeychipId.ShortValue
|
||||||
|
│
|
||||||
|
└─ 发 UpsertUserChargelogApi
|
||||||
|
returnCode == 1 -> RequestSuccess(本地 stock 再 --,因为票马上被用掉)
|
||||||
|
否则 -> RequestFail
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1 ★ `price` 不是「花的钱」
|
||||||
|
|
||||||
|
`VOExtensions.cs:410`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
result.price = ticket.creditNum;
|
||||||
|
```
|
||||||
|
|
||||||
|
`creditNum` 是**票的信用点倍数**,来自游戏本地主数据,**不是** `GetGameChargeApi` 的 money price。
|
||||||
|
|
||||||
|
| chargeId | 票名 | `creditNum`(= 上报的 price) | `GetGameChargeApi.price`(投币数) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2 | 2倍チケット | **1** | 1 |
|
||||||
|
| 3 | 3倍チケット | **2** | 2 |
|
||||||
|
| 4 | 4倍チケット | **3** | 未上架 |
|
||||||
|
| 5 | 5倍チケット | **4** | 未上架 |
|
||||||
|
| 6 | 6倍チケット | **5** | 未上架 |
|
||||||
|
|
||||||
|
> 2/3 号票两者数值恰好相同,别被误导 —— 源码取的是 `creditNum`。
|
||||||
|
> 数据已抄进 `config.TICKET_CREDIT_NUM`,来源:
|
||||||
|
> `<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml`
|
||||||
|
|
||||||
|
### 3.2 ★ `validDate` 算法
|
||||||
|
|
||||||
|
`Manager/TicketManager.cs:564`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||||||
|
```
|
||||||
|
|
||||||
|
**真机双向验证**(`source/2026-10-05.log:123` 的 `userChargeList`):
|
||||||
|
|
||||||
|
| chargeId | purchaseDate | 减 4h | 加 90d | validDate(真机) | 吻合 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 5 | 2026-10-04 18:22:10 | 10-04 14:22 | 2027-01-02 | `2027-01-02 04:00:00` | ✅ |
|
||||||
|
| 3 | 2026-10-02 10:49:23 | 10-02 06:49 | 2026-12-31 | `2026-12-31 04:00:00` | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、怎么用
|
||||||
|
|
||||||
|
### 4.1 主脚本(`buy_ticket.py`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 预检:只看报文,零副作用
|
||||||
|
python buy_ticket.py "<二维码>" --charge-id 2
|
||||||
|
|
||||||
|
# 先看看现在有什么票、能买什么
|
||||||
|
python buy_ticket.py "<二维码>" --list
|
||||||
|
|
||||||
|
# ★ 真实购买
|
||||||
|
python buy_ticket.py "<二维码>" --charge-id 2 --send
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 在代码里调
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api import (setup_logging, teardown_logging,
|
||||||
|
ParseQRCode, UserLogin, UserLogout, LogoutType,
|
||||||
|
UpsertUserChargelog)
|
||||||
|
|
||||||
|
setup_logging("买票")
|
||||||
|
try:
|
||||||
|
user_id, token = ParseQRCode(qr)
|
||||||
|
login = UserLogin(user_id, token)
|
||||||
|
|
||||||
|
# ★ 一个函数搞定:取当前票券 -> 取档案 -> 算 validDate -> 算 price -> 闸门 -> 发包
|
||||||
|
UpsertUserChargelog(user_id, 2, login.loginDateTime,
|
||||||
|
dry_run=False, confirm=True)
|
||||||
|
|
||||||
|
UserLogout(user_id, token, login.loginDateTime,
|
||||||
|
logout_type=LogoutType.TestIn)
|
||||||
|
finally:
|
||||||
|
teardown_logging()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 参数
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `user_id` / `charge_id` / `login_date_time` | 必填 | 后两个必须与 `UserLoginApi` 一致 |
|
||||||
|
| `charge_list` | 自动抓 | 当前票券列表(`GetUserChargeApi`),用来算购买后库存 |
|
||||||
|
| `user_data` | 自动抓 | 当前档案(`GetUserDataApi`),用来填 `playCount`/`playerRating` |
|
||||||
|
| `stock` | 现有 + 1 | 购买后该票的库存 |
|
||||||
|
| `price` | `TICKET_CREDIT_NUM[charge_id]` | = `ticket.creditNum` |
|
||||||
|
| `purchase_date` | 现在 | `YYYY-MM-DD HH:MM:SS` |
|
||||||
|
| `valid_date` | 由 `purchase_date` 推 | 现在 -4h +90d,取日期补 `04:00:00` |
|
||||||
|
| `play_count` / `player_rating` | 取自 `user_data` | |
|
||||||
|
| `confirm` | `config.UPSERT_CHARGELOG_CONFIRM`(False) | ★ 不开不发包 |
|
||||||
|
| `dry_run` | `False` | `True` = 只构造报文 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、买完之后票会怎样?
|
||||||
|
|
||||||
|
按源码,**买票接口自己就把 `userCharge`(含新库存)写进去了**,
|
||||||
|
不需要额外再发 `UpsertUserAll`。
|
||||||
|
|
||||||
|
真机的完整顺序是「买票 → 立刻用掉这一张」:
|
||||||
|
|
||||||
|
```
|
||||||
|
SetBuyPaidTicketData: stock++ (本地 +1)
|
||||||
|
RequestUserCharge: 发 stock(购买后的值)
|
||||||
|
RequestSuccess: stock-- (票被本局用掉了)
|
||||||
|
```
|
||||||
|
|
||||||
|
所以 `userChargeList` 里的 `stock` 平时大多是 **0** ——
|
||||||
|
真机 22:31 那次的 `userChargeList` 三条全是 `stock: 0`,就是这个原因。
|
||||||
|
|
||||||
|
> **本模块只负责「买」**,默认上报的是**购买后**的库存(现有 + 1)。
|
||||||
|
> 如果你要复刻「买完立刻用掉」的完整语义,买完再发一次
|
||||||
|
> `UpsertUserAll(..., extra_data={"userChargeList": [...]})` 把 `stock` 减回去。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、★ 真机抓包对照(已修正,报文逐字节一致)
|
||||||
|
|
||||||
|
### 6.0 结论:唯一差异是 `purchaseDate` 少了一个 `.0`
|
||||||
|
|
||||||
|
真机抓包(`MaimaiNetSpy` `raw/061_UpsertUserChargelogApiMaimaiChn_request.json`,328 字节):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"userId":12345678,
|
||||||
|
"userChargelog":{"chargeId":2,"price":1,"purchaseDate":"2026-10-06 13:20:10.0",
|
||||||
|
"playCount":1262,"playerRating":15000,"placeId":1000,
|
||||||
|
"regionId":1,"clientId":"TESTCLIENT1"},
|
||||||
|
"userCharge":{"chargeId":2,"stock":1,"purchaseDate":"2026-10-06 13:20:10.0",
|
||||||
|
"validDate":"2027-01-04 04:00:00"},
|
||||||
|
"loginDateTime":1791292785}
|
||||||
|
```
|
||||||
|
|
||||||
|
**14 个字段逐项对照:**
|
||||||
|
|
||||||
|
| 字段 | 真机 | 修正前 | 结论 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `userChargelog.chargeId` | 2 | 2 | ✅ |
|
||||||
|
| `userChargelog.price` | **1** | 1(= `creditNum`) | ✅ **源码推导正确** |
|
||||||
|
| `userChargelog.purchaseDate` | **`2026-10-06 13:20:10.0`** | `2026-10-06 13:10:42` | ❌ **少 `.0`** |
|
||||||
|
| `userChargelog.playCount` | 1262 | 1262 | ✅ |
|
||||||
|
| `userChargelog.playerRating` | 15000 | 15000 | ✅ |
|
||||||
|
| `userChargelog.placeId` / `regionId` / `clientId` | 1000 / 5 / `TESTCLIENT1` | 同 | ✅ |
|
||||||
|
| `userCharge.chargeId` | 2 | 2 | ✅ |
|
||||||
|
| `userCharge.stock` | **1** | 1(**加 1 后**) | ✅ **源码推导正确** |
|
||||||
|
| `userCharge.purchaseDate` | `2026-10-06 13:20:10.0` | 少 `.0` | ❌ |
|
||||||
|
| `userCharge.validDate` | **`2027-01-04 04:00:00`** | 同 | ✅ **算法完全正确** |
|
||||||
|
| `loginDateTime` | 1791292785 | 同 | ✅ |
|
||||||
|
| 字段顺序 | — | 同 | ✅ |
|
||||||
|
|
||||||
|
**修正后逐字节比对:真机 328 字节 / 本工程 328 字节,完全相同。**
|
||||||
|
|
||||||
|
### 6.0.1 根因:`TimeManager.DateTimeFormat` 带一位小数
|
||||||
|
|
||||||
|
`Manager/TimeManager.cs:7,62-70`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public const string DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f"; // ← .f = 一位小数
|
||||||
|
|
||||||
|
public static string GetNowDateString()
|
||||||
|
=> GetDateString(GetNowUnixTime());
|
||||||
|
|
||||||
|
public static string GetDateString(long unixTime)
|
||||||
|
=> GetDateTime(unixTime).ToString("yyyy-MM-dd HH:mm:ss.f");
|
||||||
|
```
|
||||||
|
|
||||||
|
我原来用了 `%Y-%m-%d %H:%M:%S`(无小数),所以服务端 `returnCode=0`。
|
||||||
|
|
||||||
|
> ★ 两个格式别搞混:
|
||||||
|
> | 字段 | 格式 | 例子 |
|
||||||
|
> |---|---|---|
|
||||||
|
> | `purchaseDate` | `yyyy-MM-dd HH:mm:ss.f` | `2026-10-06 13:20:10.0` |
|
||||||
|
> | `validDate` | `yyyy-MM-dd 04:00:00`(显式串) | `2027-01-04 04:00:00` |
|
||||||
|
>
|
||||||
|
> **一个带小数一个不带**,这是最容易踩的地方。
|
||||||
|
|
||||||
|
> ★ 另一个细节:`GetNowTime()` 返回的是 **`DateTime.UtcNow`**,
|
||||||
|
> `GetDateString` 再 `Add(CSToffset)` —— 所以游戏里的日期串**恒是北京时间**,
|
||||||
|
> 与本机时区无关。脚本跑在非东八区机器上时 `datetime.now()` 会错,
|
||||||
|
> 所以本模块用 `now_cst()`(UTC+8)显式计算。
|
||||||
|
|
||||||
|
### 6.1 修正前的实跑记录(2026-10-06 13:10,已废弃)
|
||||||
|
|
||||||
|
```
|
||||||
|
[购买前] 当前票券 3 条:
|
||||||
|
chargeId=2 stock=0 validDate=2026-10-12 04:00:00
|
||||||
|
chargeId=3 stock=0 validDate=2026-12-31 04:00:00
|
||||||
|
chargeId=5 stock=0 validDate=2027-01-02 04:00:00
|
||||||
|
[购买前] 持有中的功能票:0 张 ← 安全检查通过,可以买
|
||||||
|
安全检查通过:账号当前没有持有任何功能票
|
||||||
|
票名 : 2倍チケット
|
||||||
|
库存 : 0 -> 1
|
||||||
|
price : 1(= ticket.creditNum)
|
||||||
|
purchaseDate: 2026-10-06 13:10:42
|
||||||
|
validDate : 2027-01-04 04:00:00
|
||||||
|
闸门已放行,开始上传购买记录 ...
|
||||||
|
★ 购买失败:returnCode=0 apiName=com.sega.maimai.api.UpsertUserChargelogApi
|
||||||
|
```
|
||||||
|
|
||||||
|
**账号无恙**(已只读复查):`userChargeList` 仍是原来 3 条、
|
||||||
|
`playCount=1262`、无封禁。
|
||||||
|
|
||||||
|
### 6.2 这次失败排除了什么
|
||||||
|
|
||||||
|
| 结论 | 依据 |
|
||||||
|
|---|---|
|
||||||
|
| **URL / 混淆名正确** | 服务端回了 `apiName=com.sega.maimai.api.UpsertUserChargelogApi` |
|
||||||
|
| **报文格式被接受** | 不是 0 字节响应、不是解密解压失败,是**业务层拒绝**(`returnCode=0`) |
|
||||||
|
| **不是闸门问题** | 闸门已放行,确实发了包 |
|
||||||
|
| **不是「已持票」问题** | 购买前确认 3 条票 stock 全是 0 |
|
||||||
|
| **就是 `purchaseDate` 格式** | 抓包对照后确认(见 6.0) |
|
||||||
|
|
||||||
|
### 6.3 剩下的未验证点
|
||||||
|
|
||||||
|
| # | 待验证 | 现状 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 买票后是否**还要**发 `UpsertUserAll` 才持久化库存 | 源码流程里没有;抓包只抓到这一条请求,**推测不需要** |
|
||||||
|
| 2 | `validDate` 是否会被服务端按本地时区重算 | 真机发的是 `2027-01-04 04:00:00`,与本工程算法一致 |
|
||||||
|
| 3 | 服务端是否对「有效期未过的同一 chargeId」有额外限制 | 真机在 `chargeId=2`(validDate 2026-10-12 未过)的情况下照样买了,**说明没有这个限制** |
|
||||||
|
|
||||||
|
### 6.4 抓包时确认过的时序
|
||||||
|
|
||||||
|
抓到的这一次只发了 **`UpsertUserChargelogApi` 一条写请求**,
|
||||||
|
请求体里同时带了 `userCharge`(新库存)和 `userChargelog`(购买记录)——
|
||||||
|
印证了「买票接口自己就把库存写进去了」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六点五、★ 第二次实跑:`chargeId=5` 又被 `returnCode=0`(2026-10-06 13:41)
|
||||||
|
|
||||||
|
修完 `.f` 格式后再跑一次(这次买 **5 倍券**),**还是 `returnCode=0`**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[购买前] 持有中的功能票:0 张 ← 安全检查通过
|
||||||
|
安全检查通过:账号当前没有持有任何功能票
|
||||||
|
票名 : 5倍チケット
|
||||||
|
库存 : 0 -> 1
|
||||||
|
price : 4(= ticket.creditNum)
|
||||||
|
purchaseDate: 2026-10-06 13:41:43.3 ← .f 格式已正确
|
||||||
|
validDate : 2027-01-04 04:00:00
|
||||||
|
闸门已放行,开始上传购买记录 ...
|
||||||
|
★ 购买失败:returnCode=0 apiName=com.sega.maimai.api.UpsertUserChargelogApi
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5.1 根因:`chargeId=5` 根本没上架
|
||||||
|
|
||||||
|
只读查 `GetGameChargeApi`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"length":2,"gameChargeList":[
|
||||||
|
{"orderId":1,"chargeId":2,"price":1,...}, ← 只有 2 和 3
|
||||||
|
{"orderId":2,"chargeId":3,"price":2,...}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
**chargeId=5 不在清单里 → 服务端直接拒。**
|
||||||
|
|
||||||
|
### 6.5.2 源码里本来就有这道检查(我漏了)
|
||||||
|
|
||||||
|
`Manager/TicketManager.cs:137-150`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (ticket.ticketKind.id == 1)
|
||||||
|
{
|
||||||
|
GameCharge[] gameChargeDataList = Singleton<OperationManager>.Instance.GetGameChargeDataList();
|
||||||
|
for (int i = 0; i < gameChargeDataList.Length; i++)
|
||||||
|
{
|
||||||
|
GameCharge gameCharge = gameChargeDataList[i];
|
||||||
|
if (gameCharge.chargeId == ticketId) // ★ 必须在清单里找到
|
||||||
|
{
|
||||||
|
...
|
||||||
|
RequestUserCharge(playerId); // 才发购买请求
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false; // ★ 找不到 -> 不买
|
||||||
|
```
|
||||||
|
|
||||||
|
**真机也是「先查清单,找到才买」** —— 我第一版实现直接跳到发请求,漏了这一步。
|
||||||
|
|
||||||
|
### 6.5.3 已补的守卫
|
||||||
|
|
||||||
|
`UpsertUserChargelog` 新增 `guard_purchasable=True`(默认开):
|
||||||
|
|
||||||
|
- 不传 `game_charge_list` 就自动调 `GetGameChargeApi`
|
||||||
|
- `charge_id` 不在清单里 → 抛 `ChargelogGuardError`,**未发包**
|
||||||
|
|
||||||
|
`buy_ticket.py` 更把这道检查放到了**扫码之前**(`GetGameChargeApi` 不需要登录),
|
||||||
|
不满足就直接退出 —— **不浪费二维码、不产生登录会话**:
|
||||||
|
|
||||||
|
```
|
||||||
|
当前可购买 chargeId:[2, 3]
|
||||||
|
chargeId=2 2倍チケット money price=1 有效期 2021-03-18 07:00:00 ~ 2029-12-31 00:00:00
|
||||||
|
chargeId=3 3倍チケット money price=2 有效期 2021-03-18 07:00:00 ~ 2029-12-31 00:00:00
|
||||||
|
[错误] chargeId=5(5倍チケット)**不在当前可购买清单里** —— 连登录都不做,直接退出。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5.4 本机台到底能买什么
|
||||||
|
|
||||||
|
| chargeId | 票名 | 上架 | `creditNum`(上报的 price) | money price |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 2 | 2倍チケット | ✅ | 1 | 1 |
|
||||||
|
| 3 | 3倍チケット | ✅ | 2 | 2 |
|
||||||
|
| 4 | 4倍チケット | ❌ | 3 | — |
|
||||||
|
| 5 | 5倍チケット | ❌ | 4 | — |
|
||||||
|
| 6 | 6倍チケット | ❌ | 5 | — |
|
||||||
|
|
||||||
|
> ★ 上架清单是**机台/店铺配置**,不同店铺可能不一样。
|
||||||
|
> 每次买之前都该先看 `GetGameChargeApi`。
|
||||||
|
|
||||||
|
### 6.5.5 账号状态
|
||||||
|
|
||||||
|
两次失败都**没有写入任何东西**:`userChargeList` 三条 `stock` 全是 0。
|
||||||
|
服务端的 `returnCode=0` 是「干净拒绝」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六点六、★★ 第三、四次实跑:`chargeId=2`(真机验证过的配置)**也失败**
|
||||||
|
|
||||||
|
### 6.6.1 做了什么
|
||||||
|
|
||||||
|
第 3 次(13:50,chargeId=3)和第 4 次(14:03,**chargeId=2**)分别补齐了:
|
||||||
|
|
||||||
|
| 补齐项 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `number` 头 | 加了 `Ping()`,从 `"0"` 变成 `"61"`(真机 75) |
|
||||||
|
| 入场全量下载 | 登录后先拉 18~19 个节点,复刻真机 [14]~[60] |
|
||||||
|
| 买票前等待 | 加 5 秒,贴近真机 login→买票 的 25 秒节奏 |
|
||||||
|
|
||||||
|
**结果仍然是 `returnCode=0`。**
|
||||||
|
|
||||||
|
### 6.6.2 与真机报文的最终并排对比
|
||||||
|
|
||||||
|
第 4 次的报文 vs 真机抓包:
|
||||||
|
|
||||||
|
| 路径 | 真机 | 本工程 |
|
||||||
|
|---|---|---|
|
||||||
|
| 键集合 | — | **完全相同** |
|
||||||
|
| `userChargelog` 键序 | — | **完全相同** |
|
||||||
|
| `userCharge` 键序 | — | **完全相同** |
|
||||||
|
| `userChargelog.purchaseDate` | `2026-10-06 13:20:10.0` | `2026-10-06 14:03:14.6` |
|
||||||
|
| `userChargelog.playCount` | 1262 | 1263 |
|
||||||
|
| `userCharge.purchaseDate` | `2026-10-06 13:20:10.0` | `2026-10-06 14:03:14.6` |
|
||||||
|
| `loginDateTime` | 1791292785 | 1791295385 |
|
||||||
|
|
||||||
|
**唯一不同的就是这 3 个「当下时刻」类的值。** 其余 11 个字段逐字节相同。
|
||||||
|
|
||||||
|
### 6.6.3 已逐项排除的可能
|
||||||
|
|
||||||
|
| # | 假设 | 排除依据 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | URL / 混淆名错 | `4411a4c8f7f5c0e95a3ac17f1746aad3` 与真机一致;服务端回正确 `apiName` |
|
||||||
|
| 2 | User-Agent 错 | 与真机逐字符相同 |
|
||||||
|
| 3 | Cookie 缺失 | 登录响应确实下发 `Set-Cookie: JSESSIONID=...`,Session 自动带 |
|
||||||
|
| 4 | 报文字段/顺序/类型错 | 与真机逐字节同构 |
|
||||||
|
| 5 | `purchaseDate` 格式错 | 已补 `.f`(`14:03:14.6`) |
|
||||||
|
| 6 | 票未上架 | `chargeId=2` 在 `GetGameChargeApi` 清单里 |
|
||||||
|
| 7 | 已持有功能票 | 购买前确认 3 条 `stock` 全 0 |
|
||||||
|
| 8 | `number` 头为 0 | 已加 `Ping()` → `"61"` |
|
||||||
|
| 9 | 缺少入场数据下载 | 已补(18~19 节点,0 失败) |
|
||||||
|
| 10 | 节奏太快 | 已加 5 秒等待 |
|
||||||
|
| 11 | `chargeId` 本身有问题 | **改用真机验证过的 `chargeId=2` 仍然失败** |
|
||||||
|
|
||||||
|
### 6.6.4 剩余假设
|
||||||
|
|
||||||
|
请求体已经被证明「结构上完全正确」,所以问题只可能在**请求之外**:
|
||||||
|
|
||||||
|
| # | 假设 | 怎么验证 |
|
||||||
|
|---|---|---|
|
||||||
|
| **A** | ★ **服务端有「购买频率/每日上限」规则** —— 账号在 **13:20 刚买过同一张票**,43 分钟后再买被拒 | **在真机上再买一次 2 倍券**:若真机也失败 → 规则成立,本工程实现正确;若真机成功 → 本工程有 bug |
|
||||||
|
| B | 服务端对**金钱相关接口**做了 TLS/HTTP 指纹校验(非游戏客户端特征) | 需要本地 MITM 代理对比完整 HTTP 请求头 |
|
||||||
|
| C | 服务端要求某个本工程不知道的前置状态 | 同上 |
|
||||||
|
|
||||||
|
**★ 假设 A 是最可能、也最容易验证的**:`returnCode=0` 只用了 **47 ms** 就返回,
|
||||||
|
像是命中了一条规则检查,而不是超时或异常。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、安全闸门
|
||||||
|
|
||||||
|
| 调用方式 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| `UpsertUserChargelog(...)` | 抛 `ChargelogConfirmError`,**未发包** |
|
||||||
|
| `UpsertUserChargelog(..., dry_run=True)` | 只构造并返回报文 |
|
||||||
|
| `UpsertUserChargelog(..., confirm=True)` | 真实发送 |
|
||||||
|
|
||||||
|
全局开关:`config.UPSERT_CHARGELOG_CONFIRM = False`(改成 `True` 会永久放行,不建议)。
|
||||||
+255
@@ -0,0 +1,255 @@
|
|||||||
|
# 舞萌DX 纯网络脚本工程 · 项目总览
|
||||||
|
|
||||||
|
> 项目名 **WaHappy** —— 舞萌DX 的游戏助手。
|
||||||
|
> 面向使用者的说明见根目录 [`README.md`](../README.md);
|
||||||
|
> 本文件是**开发向的主文档**,开工前先读它;遇到「该放哪、该怎么写」的问题以本文件为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、这个工程是什么
|
||||||
|
|
||||||
|
用 **Python 脚本**,通过**纯网络(纯 HTTP)** 形式直接与舞萌DX(SDGB 国服)的**游戏服务器**交互,
|
||||||
|
复刻游戏客户端的协议行为。
|
||||||
|
|
||||||
|
| 对象 | 是什么 | 对应反编译源码 |
|
||||||
|
|---|---|---|
|
||||||
|
| **游戏服务器** | 玩家数据读写:扫码登录、取数、上传成绩、登出 | `source/Assembly-CSharp/` |
|
||||||
|
| **纯网络形式** | 脚本**直接收发 HTTP 报文**(AES 加密 JSON),不依赖运行中的游戏客户端 | `source/Assembly-CSharp/Net/` |
|
||||||
|
| **扫码登录** | 微信二维码换 userId + token,走 aimedb | `source/ChimeLib.NET/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
wahappy-v0-goal/
|
||||||
|
├── config.py ★ 运行时可配置项(密钥/设备/区域/歌曲数据/日志级别)
|
||||||
|
├── upsert.py ★ 主脚本范例:扫码登录 → 上传成绩 → 登出
|
||||||
|
├── buy_ticket.py ★ 主脚本范例:扫码登录 → 购买功能票 → 登出
|
||||||
|
├── selftest.py 零网络自检(220 项,判据=源码+真机日志+真机抓包)
|
||||||
|
│
|
||||||
|
├── api/ ★ 所有与服务器交互的脚本(一个 api 一个脚本)
|
||||||
|
│ ├── __init__.py 统一导出 + 日志跟踪登记
|
||||||
|
│ ├── log_setup.py 统一日志(INFO / DEBUG 双模式)
|
||||||
|
│ ├── maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
|
||||||
|
│ ├── user_getter_common.py 取数脚本公共底座
|
||||||
|
│ ├── chime.py 扫码登录
|
||||||
|
│ ├── upsert_user_all.py ★ 上传成绩
|
||||||
|
│ └── ... 其余见第六节
|
||||||
|
│
|
||||||
|
├── source/ ★ 反编译源码 + 网络日志,只读查证用
|
||||||
|
├── docs/ ★ 文档
|
||||||
|
└── logs/ ★ 日志输出(latest.log + 日期.log)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、api/ 脚本契约
|
||||||
|
|
||||||
|
`api/` 里的每个脚本是一个**功能模块**,对外暴露**函数**;函数**能接收参数**,
|
||||||
|
内部完成「组包 → 发包 → 解析 → 返回结果」的全过程。
|
||||||
|
|
||||||
|
```python
|
||||||
|
def 某Api函数(必填参数, *, 可选参数...) -> 结果:
|
||||||
|
"""一句话说明(会出现在日志的 [接口说明] 里)"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
参数形态规律:
|
||||||
|
|
||||||
|
- **业务数据**(user_id / token / 二维码 / playlog_id)→ **位置参数**
|
||||||
|
- **配置项**(client_id / region_id / timeout_ms)→ **关键字参数**,可省略走默认值
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、日志
|
||||||
|
|
||||||
|
### 4.1 两种模式
|
||||||
|
|
||||||
|
| 模式 | 输出内容 |
|
||||||
|
|---|---|
|
||||||
|
| **INFO**(默认) | 只展示 api 脚本的**运行情况和结果** |
|
||||||
|
| **DEBUG** | 在 INFO 基础上,额外展示**向 api 发送的数据**和**api 返回的数据** |
|
||||||
|
|
||||||
|
切换只改 `config.py` 一行:
|
||||||
|
|
||||||
|
```python
|
||||||
|
LOG_LEVEL = "info" # 只看流程
|
||||||
|
LOG_LEVEL = "debug" # 连收发数据一起看
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 运行提示
|
||||||
|
|
||||||
|
每个 api 函数工作时会提示开始,结束时提示结束:
|
||||||
|
|
||||||
|
```
|
||||||
|
=== ParseQRCode 开始运行 ===
|
||||||
|
[接口说明] 解析二维码并换取 userId 与 token。
|
||||||
|
[位置参数] 'SGWCMAID2610022219463A297AF4CA...'
|
||||||
|
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
|
||||||
|
=== ParseQRCode 运行结束 ===
|
||||||
|
```
|
||||||
|
|
||||||
|
DEBUG 模式额外输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
-> 请求 GetGameSettingApi
|
||||||
|
URL : https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/83a5...
|
||||||
|
请求头 : {"Content-Type":"application/json","Mai-Encoding":"1.55"}
|
||||||
|
请求体 :
|
||||||
|
{
|
||||||
|
"placeId": 1000,
|
||||||
|
"clientId": "TESTCLIENT1"
|
||||||
|
}
|
||||||
|
|
||||||
|
<- 响应 GetGameSettingApi
|
||||||
|
状态码 : 200
|
||||||
|
耗时 : 312 ms
|
||||||
|
响应体 :
|
||||||
|
{
|
||||||
|
"isAouAccession": false,
|
||||||
|
"gameSetting": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 日志文件(两种形式同时存在、同时写入)
|
||||||
|
|
||||||
|
| 文件 | 模式 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `logs/latest.log` | **覆盖**(`w`) | 只包含**当次**主脚本运行产出的日志,下次运行被覆盖 |
|
||||||
|
| `logs/YYYY-MM-DD.log` | **追加**(`a`) | 存放**当日**产生的所有日志 |
|
||||||
|
|
||||||
|
每行都带 `[YYYY-MM-DD HH:MM:SS]` 时间戳。
|
||||||
|
|
||||||
|
### 4.4 主脚本怎么写
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api import setup_logging, teardown_logging, ParseQRCode, UserLogin
|
||||||
|
|
||||||
|
def main():
|
||||||
|
setup_logging("我的主脚本") # 开头一句;api 调用从此自动记录
|
||||||
|
try:
|
||||||
|
user_id, token = ParseQRCode(qr)
|
||||||
|
login = UserLogin(user_id, token)
|
||||||
|
finally:
|
||||||
|
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
|
||||||
|
```
|
||||||
|
|
||||||
|
**不需要**写装饰器,**不需要**自己埋点 —— `api/__init__.py` 在导入时就统一套好了日志外壳。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、协议实现要点(全部有源码依据)
|
||||||
|
|
||||||
|
| 环节 | 依据 | 要点 |
|
||||||
|
|---|---|---|
|
||||||
|
| **URL 生成** | `Packet.cs:485` + `NetQuery.cs:14` | BASE_URL + md5(接口名 + "MaimaiChn" + "8bF76dE9") |
|
||||||
|
| **请求封装** | `NetHttpClient.cs:262-300` | JSON → 原始 deflate → 0x78 0x9C + Adler32(大端) → AES-256-CBC |
|
||||||
|
| **响应解封装** | `NetHttpClient.cs:355-380` | AES 解密 → 去 2 字节头 / 4 字节尾 → deflate → JSON |
|
||||||
|
| **AES 参数** | `CipherAES.cs:11-12` | Key 32B / IV 16B / CBC / PKCS7 |
|
||||||
|
| **请求头** | `NetHttpClient.cs:174-190` | Mai-Encoding: 1.55、Content-Encoding: deflate、UA = 混淆名#userId |
|
||||||
|
| **number 头** | `Packet.cs:187-189` + `PingChecker.cs:227` | = Ping 心跳的往返毫秒(`GameManager.PingMsec`),**只由 Ping 刷新**;除 Ping 外每个接口都带 |
|
||||||
|
| **Cookie** | `Packet.cs:190-193,228-234` | ★ **UserLoginApi 响应会下发 `Set-Cookie: JSESSIONID=...`**,之后每个带 userId 的请求都会带上;`requests.Session` 自动处理,但**必须复用同一个 client**(`get_default_client()`) |
|
||||||
|
| **playSpecial** | `VOExtensions.cs:846-855` + `GameManager.cs:332-378` | `位反转32(Random(1,1037933)*2069 + CalcSpecialNum)`;标准 ACA 机台 `CalcSpecialNum = 1024 = 0x400` |
|
||||||
|
| **扫码签名** | 技术规范 §4 | SHA256(chipID + timestamp + KEY) 大写;**不带 Content-Type** |
|
||||||
|
| **loginDateTime** | `PacketUserLogin.cs:29` | 「本地墙上时间当 UTC」= 标准 Unix 秒 + 时区偏移 |
|
||||||
|
|
||||||
|
### ★ 一个必须注意的坑:Accept-Encoding 必须为空
|
||||||
|
|
||||||
|
`requests` 默认发 `Accept-Encoding: gzip, deflate`。本协议响应体是 AES 密文(二进制),
|
||||||
|
服务端若据此做内容编码会返回**空 body**。C# 端不需要是因为 `HttpWebRequest`
|
||||||
|
默认 `AutomaticDecompression = None`。所以 `maimai_net.py` 固定置空该头。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、API 清单
|
||||||
|
|
||||||
|
### 6.1 会话链路(6 个)
|
||||||
|
|
||||||
|
| 脚本 | 函数 | 对应接口 |
|
||||||
|
|---|---|---|
|
||||||
|
| `chime.py` | `ParseQRCode(qr, *, chip_id)` | aimedb /wc_aime/api/get_data |
|
||||||
|
| `gamesettings.py` | `GetGameSettings(*, place_id, client_id)` | GetGameSettingApi |
|
||||||
|
| `ping.py` | `Ping(*, client_id)` | Ping |
|
||||||
|
| `userpreview.py` | `UserPreview(user_id, token, *, ...)` | GetUserPreviewApi |
|
||||||
|
| `userlogin.py` | `UserLogin(user_id, token, *, region_id, place_id)` | UserLoginApi |
|
||||||
|
| `userlogout.py` | `UserLogout(user_id, token, login_dt, *, logout_type)` | UserLogoutApi |
|
||||||
|
|
||||||
|
### 6.2 上传成绩(2 个)
|
||||||
|
|
||||||
|
| 脚本 | 函数 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `upsert_user_all.py` | `UpsertUserAll(user_id, playlog_id, *, login_date_time, auth_date_time, token, snapshot, extra_data, play, ...)` | ★ **一个函数跑完整条结算链路**:抓快照 → 组包 → 前置接口 → 自检 → 60 秒冷却 → 发包 → 落库回查。唯一有副作用的接口 |
|
||||||
|
| `upsert_user_all.py` | `derive_play_characters(snapshot)` | 从服务器快照推「本局 5 只角色 + 游玩前等级」,免得手填的 `config.PLAY_CHARACTERS` 过期 |
|
||||||
|
| `upsert_user_all.py` | `load_extra_data(extra)` / `merge_extra_data(user_all, extra)` | 把外部 JSON(dict / JSON 串 / 文件路径)自动匹配插入 36 节点 |
|
||||||
|
| `upsert_user_all.py` | `verify_upsert_landing(user_id, snapshot, play)` | 发包后重新拉服务器数据,确认真的落库 |
|
||||||
|
| `upsert_snapshot.py` | `fetch_upsert_snapshot(user_id, ...)` | 一键抓结算基线快照(`UpsertUserAll` 内部会按需自动调) |
|
||||||
|
| `upsert_user_chargelog.py` | `UpsertUserChargelog(user_id, charge_id, login_date_time, ...)` | ★ **购买功能票**(UpsertUserChargelogApi):自动取当前票券/档案、算 validDate 与 price、闸门、发包 |
|
||||||
|
| `upsert_user_chargelog.py` | `calc_valid_date(now)` / `ticket_price(id)` / `ticket_name(id)` | 有效期算法(-4h+90d)、票的 creditNum、票名 |
|
||||||
|
|
||||||
|
### 6.3 取数(一个接口一个脚本)
|
||||||
|
|
||||||
|
| 脚本 | 函数 | 对应接口 |
|
||||||
|
|---|---|---|
|
||||||
|
| `userdata.py` | UserData | GetUserDataApi |
|
||||||
|
| `user_extend.py` | UserExtend | GetUserExtendApi |
|
||||||
|
| `user_option.py` | UserOption | GetUserOptionApi |
|
||||||
|
| `user_character.py` | UserCharacter | GetUserCharacterApi |
|
||||||
|
| `user_ghost.py` | UserGhost | GetUserGhostApi |
|
||||||
|
| `user_map.py` | UserMap / UserMapAll | GetUserMapApi |
|
||||||
|
| `user_login_bonus.py` | UserLoginBonus / UserLoginBonusAll | GetUserLoginBonusApi |
|
||||||
|
| `user_rating.py` | UserRating | GetUserRatingApi |
|
||||||
|
| `user_item.py` | UserItem / UserItemAll | GetUserItemApi |
|
||||||
|
| `user_music.py` | UserMusicDetail / UserMusicDetailAll | GetUserMusicApi |
|
||||||
|
| `user_course.py` | UserCourse / UserCourseAll | GetUserCourseApi |
|
||||||
|
| `user_charge.py` | UserCharge | GetUserChargeApi |
|
||||||
|
| `user_favorite.py` | UserFavorite / UserFavoriteAll | GetUserFavoriteApi |
|
||||||
|
| `user_activity.py` | UserActivity | GetUserActivityApi |
|
||||||
|
| `user_mission.py` | UserMissionData | GetUserMissionDataApi |
|
||||||
|
| `user_intimate.py` | UserIntimate | GetUserIntimateApi |
|
||||||
|
| `user_shop_stock.py` | UserShopStock | GetUserShopStockApi |
|
||||||
|
| `user_favorite_item.py` | UserFavoriteItem / UserFavoriteItemAll | GetUserFavoriteItemApi |
|
||||||
|
| `user_rival.py` | UserRivalData / UserRivalMusic(All) | GetUserRivalDataApi / GetUserRivalMusicApi |
|
||||||
|
| `user_friend_bonus.py` | UserFriendBonus | GetUserFriendBonusApi |
|
||||||
|
| `user_kaleidx_scope.py` | UserKaleidxScope / GameKaleidxScope | GetUserKaleidxScopeApi / GetGameKaleidxScopeApi |
|
||||||
|
| `user_region.py` | UserRegion | GetUserRegionApi |
|
||||||
|
| `user_recommend_music.py` | UserRecommendRateMusic / UserRecommendSelectMusic | GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi |
|
||||||
|
| `user_new_item.py` | UserNewItemList | GetUserNewItemListApi |
|
||||||
|
| `game_ranking.py` | GetGameRanking | GetGameRankingApi |
|
||||||
|
| `game_event.py` | GetGameEvent | GetGameEventApi |
|
||||||
|
| `game_charge.py` | GetGameCharge | GetGameChargeApi |
|
||||||
|
| `game_weekly_data.py` | GetGameWeeklyData | GetGameWeeklyDataApi |
|
||||||
|
| `game_tournament_info.py` | GetGameTournamentInfo | GetGameTournamentInfoApi |
|
||||||
|
| `game_ng_music_id.py` | GetGameNgMusicId | GetGameNgMusicIdApi |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、新增一个 api 要做什么
|
||||||
|
|
||||||
|
1. 在 `api/` 下新建脚本,定义一个能传参的函数
|
||||||
|
2. 函数首行写 docstring(会变成日志的 `[接口说明]`)
|
||||||
|
3. 用 `from .log_setup import log_info, log_request, log_response` 记日志
|
||||||
|
4. 用 `from .maimai_net import request_dict` 发包
|
||||||
|
5. 在 `api/__init__.py` 里**导入**并**加进 `_API_FUNCS`** ← 漏了这步就不会有运行提示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、相关文档
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `docs/上传成绩开发文档.md` | UpsertUserAll 的 36 节点 / 110 字段 / 差分语义 / 真机对照 |
|
||||||
|
| `docs/主脚本编写指南.md` | ★ **写自己的主脚本**:三条铁律 / 最小骨架 / 四个流程模板 / 常见错误 |
|
||||||
|
| `docs/购买功能票流程.md` | ★ 登录 → 买票 → 登出:接口结构 / 源码流程 / validDate 算法 / 待抓包确认项 |
|
||||||
|
| `docs/UpsertUserAll踩坑清单.md` | ★ **28 条坑的逐条记录**:现象 / 根因 / 源码依据 / 正确做法 / 自检项 + 发包前 Checklist |
|
||||||
|
| `docs/实跑验证记录.md` | ★ **真实跑通记录**:returnCode=1 + 回读确认 playCount 1260→1261,含完整时间线与落库证据 |
|
||||||
|
| `docs/真机Hook对照报告.md` | ★ MelonLoader Mod 真机抓包(70 请求)与本实现的逐项对照:number / Cookie / CalcSpecialNum / 剩余差异说明 |
|
||||||
|
| `docs/UpsertUserAll实跑对比.md` | 逐字段对比明细(110 字段 × 真机 vs 本工程) |
|
||||||
|
| `docs/UpsertUserAll落库排查报告.md` | 「returnCode=1 但没落库」的根因分析 |
|
||||||
|
| `docs/日志与主脚本指南.md` | 日志模块完整参考 + 主脚本写法 |
|
||||||
|
| `docs/舞萌DX_扫码登录协议技术规范.md` | 扫码登录协议(上一代整理) |
|
||||||
|
| `source/2026-10-05.log` | 真机网络日志(本工程最重要的判据) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*新增 api 后请同步更新第六节清单。*
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
MaimaiNetSpy —— MelonLoader Mod:hook 舞萌DX 游戏网络层做实时拦截
|
|
||||||
==========================================================================
|
|
||||||
目标框架必须与游戏/MelonLoader 一致:net472(MonoBleedingEdge + MelonLoader 0.6.4)
|
|
||||||
构建:dotnet build -c Release
|
|
||||||
产物:bin/Release/net472/MaimaiNetSpy.dll -> 复制到 D:\SDGB2026\Package\Mods\
|
|
||||||
-->
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net472</TargetFramework>
|
|
||||||
<AssemblyName>MaimaiNetSpy</AssemblyName>
|
|
||||||
<RootNamespace>MaimaiNetSpy</RootNamespace>
|
|
||||||
<LangVersion>latest</LangVersion>
|
|
||||||
<Nullable>disable</Nullable>
|
|
||||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
|
||||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
|
||||||
<Version>1.0.0</Version>
|
|
||||||
<!-- 不生成 apphost / 不需要运行时配置 -->
|
|
||||||
<UseAppHost>false</UseAppHost>
|
|
||||||
<GenerateRuntimeConfigurationFiles>false</GenerateRuntimeConfigurationFiles>
|
|
||||||
<!-- 允许引用 net472 参考程序集 -->
|
|
||||||
<AutomaticallyUseReferenceAssemblyPackages>true</AutomaticallyUseReferenceAssemblyPackages>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<!-- 游戏程序集与 MelonLoader 的引用(全部 Private=false,不复制到输出) -->
|
|
||||||
<ItemGroup>
|
|
||||||
<Reference Include="MelonLoader">
|
|
||||||
<HintPath>D:\SDGB2026\Package\MelonLoader\net35\MelonLoader.dll</HintPath>
|
|
||||||
<Private>false</Private>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="0Harmony">
|
|
||||||
<HintPath>D:\SDGB2026\Package\MelonLoader\net35\0Harmony.dll</HintPath>
|
|
||||||
<Private>false</Private>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="Assembly-CSharp">
|
|
||||||
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\Assembly-CSharp.dll</HintPath>
|
|
||||||
<Private>false</Private>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="Assembly-CSharp-firstpass">
|
|
||||||
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\Assembly-CSharp-firstpass.dll</HintPath>
|
|
||||||
<Private>false</Private>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="UnityEngine">
|
|
||||||
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\UnityEngine.dll</HintPath>
|
|
||||||
<Private>false</Private>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="UnityEngine.CoreModule">
|
|
||||||
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
|
|
||||||
<Private>false</Private>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="AMDaemon.NET">
|
|
||||||
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\AMDaemon.NET.dll</HintPath>
|
|
||||||
<Private>false</Private>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="ChimeLib.NET">
|
|
||||||
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\ChimeLib.NET.dll</HintPath>
|
|
||||||
<Private>false</Private>
|
|
||||||
</Reference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Text;
|
|
||||||
using HarmonyLib;
|
|
||||||
using MelonLoader;
|
|
||||||
|
|
||||||
// ★ MelonLoader 靠这个特性识别 mod 并调用 OnInitializeMelon。
|
|
||||||
// 缺了它只会 "Melon Assembly loaded" 但不会实例化(表现为 "N Mods loaded" 不计入)。
|
|
||||||
[assembly: MelonInfo(typeof(MaimaiNetSpy.Main), "MaimaiNetSpy", "1.0.0", "wahappy")]
|
|
||||||
[assembly: MelonGame(null, null)]
|
|
||||||
[assembly: MelonColor(255, 120, 200, 255)]
|
|
||||||
|
|
||||||
namespace MaimaiNetSpy
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// MaimaiNetSpy —— hook 舞萌DX 游戏网络层,做实时拦截与落盘。
|
|
||||||
///
|
|
||||||
/// 拦截点(全部依据 Assembly-CSharp 的实际签名):
|
|
||||||
/// Net.NetHttpClient.AddHeader(name, value) 所有请求头
|
|
||||||
/// Net.NetHttpClient.AddCookie(userId) Cookie
|
|
||||||
/// Net.NetHttpClient.Request(bytes, ua, method) ★ 加密前的明文 JSON
|
|
||||||
/// Net.NetHttpClient.GetResponse() 解密后的响应
|
|
||||||
/// Net.NetPacketUtil.GetUserAgent(query) 逻辑 API 名
|
|
||||||
/// Manager.GameManager.CalcSpecialNum() playSpecial 的硬件标志位
|
|
||||||
///
|
|
||||||
/// 输出:
|
|
||||||
/// <游戏根目录>/MaimaiNetSpy/YYYY-MM-DD_HHmmss.log 人类可读时间线
|
|
||||||
/// <游戏根目录>/MaimaiNetSpy/raw/*.json 每个接口的请求/响应原文
|
|
||||||
/// </summary>
|
|
||||||
public class Main : MelonMod
|
|
||||||
{
|
|
||||||
internal static MelonLogger.Instance Log;
|
|
||||||
|
|
||||||
//: 当前请求的上下文(按线程隔离,游戏是异步回调模型)
|
|
||||||
private static readonly object _lock = new object();
|
|
||||||
|
|
||||||
private static string _outDir;
|
|
||||||
private static StreamWriter _writer;
|
|
||||||
|
|
||||||
//: 本次请求累积的头部(AddHeader 逐条调用,Request 时才真正发出)
|
|
||||||
[ThreadStatic] private static List<KeyValuePair<string, string>> _headers;
|
|
||||||
[ThreadStatic] private static string _cookie;
|
|
||||||
[ThreadStatic] private static string _currentApi;
|
|
||||||
[ThreadStatic] private static string _currentUrl;
|
|
||||||
|
|
||||||
//: 统计
|
|
||||||
private static int _reqCount;
|
|
||||||
private static int _respCount;
|
|
||||||
|
|
||||||
public override void OnInitializeMelon()
|
|
||||||
{
|
|
||||||
Log = LoggerInstance;
|
|
||||||
|
|
||||||
string root = MelonLoader.Utils.MelonEnvironment.GameRootDirectory;
|
|
||||||
_outDir = Path.Combine(root, "MaimaiNetSpy");
|
|
||||||
Directory.CreateDirectory(_outDir);
|
|
||||||
Directory.CreateDirectory(Path.Combine(_outDir, "raw"));
|
|
||||||
|
|
||||||
string file = Path.Combine(_outDir,
|
|
||||||
DateTime.Now.ToString("yyyy-MM-dd_HHmmss") + ".log");
|
|
||||||
_writer = new StreamWriter(file, false, new UTF8Encoding(false)) { AutoFlush = true };
|
|
||||||
|
|
||||||
Line("=".PadRight(78, '='));
|
|
||||||
Line(" MaimaiNetSpy 已加载 —— 舞萌DX 网络层实时拦截");
|
|
||||||
Line(" 日志: " + file);
|
|
||||||
Line("=".PadRight(78, '='));
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var h = new HarmonyLib.Harmony("MaimaiNetSpy");
|
|
||||||
int n = 0;
|
|
||||||
|
|
||||||
// ★ Request 有两个重载:Request() 与 Request(byte[], string, string)
|
|
||||||
// 必须按参数类型精确匹配,否则会命中无参版本导致
|
|
||||||
// "Parameter bytes not found in method Request()"
|
|
||||||
n += PatchBySig(h, "Net.NetHttpClient", "Create",
|
|
||||||
new[] { typeof(string) },
|
|
||||||
nameof(PreCreate), null);
|
|
||||||
n += PatchBySig(h, "Net.NetHttpClient", "AddHeader",
|
|
||||||
new[] { typeof(string), typeof(string) },
|
|
||||||
nameof(PreAddHeader), null);
|
|
||||||
n += PatchBySig(h, "Net.NetHttpClient", "AddCookie",
|
|
||||||
new[] { typeof(ulong) },
|
|
||||||
nameof(PreAddCookie), null);
|
|
||||||
n += PatchBySig(h, "Net.NetHttpClient", "Request",
|
|
||||||
new[] { typeof(byte[]), typeof(string), typeof(string) },
|
|
||||||
nameof(PreRequest), nameof(PostRequestDumpHeaders));
|
|
||||||
n += PatchBySig(h, "Net.NetHttpClient", "GetResponse",
|
|
||||||
Type.EmptyTypes,
|
|
||||||
null, nameof(PostGetResponse));
|
|
||||||
n += PatchBySig(h, "Net.NetPacketUtil", "GetUserAgent",
|
|
||||||
new[] { AccessTools.TypeByName("Net.INetQuery") },
|
|
||||||
null, nameof(PostGetUserAgent));
|
|
||||||
n += PatchBySig(h, "Manager.GameManager", "CalcSpecialNum",
|
|
||||||
Type.EmptyTypes,
|
|
||||||
null, nameof(PostCalcSpecialNum));
|
|
||||||
n += PatchBySig(h, "Net.NetHttpClient", "SetError",
|
|
||||||
new[] { typeof(System.Net.WebExceptionStatus), typeof(string),
|
|
||||||
typeof(int), typeof(System.Net.HttpWebResponse) },
|
|
||||||
nameof(PreSetError), null);
|
|
||||||
|
|
||||||
Line(" 已挂载 " + n + " 个 hook");
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Line(" !! hook 挂载失败: " + e);
|
|
||||||
}
|
|
||||||
Line("");
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void OnDeinitializeMelon()
|
|
||||||
{
|
|
||||||
try { _writer?.Flush(); _writer?.Dispose(); } catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// hook 挂载工具
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 按**参数类型**精确挂载 hook。
|
|
||||||
///
|
|
||||||
/// ★ 为什么必须精确匹配:NetHttpClient.Request 有两个重载
|
|
||||||
/// Boolean Request() —— 只发请求(无体)
|
|
||||||
/// Boolean Request(Byte[] bytes, String ua, String m) —— 带请求体
|
|
||||||
/// 用 AccessTools.Method(t, "Request") 会命中前者,
|
|
||||||
/// 导致 prefix 里的 bytes 参数对不上而抛
|
|
||||||
/// "Parameter bytes not found in method Request()"。
|
|
||||||
/// </summary>
|
|
||||||
private static int PatchBySig(HarmonyLib.Harmony h, string typeName, string methodName,
|
|
||||||
Type[] paramTypes, string pre, string post)
|
|
||||||
{
|
|
||||||
var t = AccessTools.TypeByName(typeName);
|
|
||||||
if (t == null) { Line(" [skip] 类型未找到: " + typeName); return 0; }
|
|
||||||
if (paramTypes == null || paramTypes.Any(x => x == null))
|
|
||||||
{ Line(" [skip] 参数类型解析失败: " + typeName + "." + methodName); return 0; }
|
|
||||||
|
|
||||||
var m = AccessTools.Method(t, methodName, paramTypes);
|
|
||||||
if (m == null)
|
|
||||||
{
|
|
||||||
Line(" [skip] 方法未找到: " + typeName + "." + methodName
|
|
||||||
+ "(" + string.Join(", ", Array.ConvertAll(paramTypes, x => x.Name)) + ")");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
HarmonyMethod preM = pre == null ? null
|
|
||||||
: new HarmonyMethod(typeof(Main).GetMethod(pre, BindingFlags.Static | BindingFlags.NonPublic));
|
|
||||||
HarmonyMethod postM = post == null ? null
|
|
||||||
: new HarmonyMethod(typeof(Main).GetMethod(post, BindingFlags.Static | BindingFlags.NonPublic));
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
h.Patch(m, preM, postM);
|
|
||||||
Line(" [ok] " + typeName + "." + methodName
|
|
||||||
+ "(" + string.Join(", ", Array.ConvertAll(paramTypes, x => x.Name)) + ")");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Line(" [fail] " + typeName + "." + methodName + " -> " + e.Message);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Create —— 捕获完整 URL(NetHttpClient.Create(url))
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static void PreCreate(string url)
|
|
||||||
{
|
|
||||||
_currentUrl = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// AddHeader —— 收集请求头
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static void PreAddHeader(string name, string value)
|
|
||||||
{
|
|
||||||
if (_headers == null) _headers = new List<KeyValuePair<string, string>>();
|
|
||||||
_headers.Add(new KeyValuePair<string, string>(name, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PostAddHeader()
|
|
||||||
{
|
|
||||||
// 不在这里输出:AddHeader 会被多次调用,等 Request 时统一打印
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// AddCookie —— 记录 Cookie
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static void PreAddCookie(ulong userId)
|
|
||||||
{
|
|
||||||
_cookie = "userId=" + userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Request —— ★ 核心:加密前的明文 JSON 在这里
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static void PreRequest(byte[] bytes, string userAgent, string method)
|
|
||||||
{
|
|
||||||
_reqCount++;
|
|
||||||
string plain = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (bytes != null && bytes.Length > 0)
|
|
||||||
plain = Encoding.UTF8.GetString(bytes);
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine("+" + new string('-', 76));
|
|
||||||
sb.AppendLine("| [" + _reqCount + "] >>> 请求 "
|
|
||||||
+ DateTime.Now.ToString("HH:mm:ss.fff"));
|
|
||||||
sb.AppendLine("+" + new string('-', 76));
|
|
||||||
sb.AppendLine("| API : " + (_currentApi ?? "(unknown)"));
|
|
||||||
if (!string.IsNullOrEmpty(_currentUrl))
|
|
||||||
sb.AppendLine("| URL : " + _currentUrl);
|
|
||||||
sb.AppendLine("| User-Agent : " + userAgent);
|
|
||||||
sb.AppendLine("| Method : " + method);
|
|
||||||
sb.AppendLine("| Cookie : " + (_cookie ?? "(none)"));
|
|
||||||
sb.AppendLine("| 明文长度 : " + (bytes == null ? 0 : bytes.Length) + " 字节");
|
|
||||||
|
|
||||||
if (_headers != null && _headers.Count > 0)
|
|
||||||
{
|
|
||||||
sb.AppendLine("| ---- 请求头(AddHeader 收集)----");
|
|
||||||
foreach (var kv in _headers)
|
|
||||||
sb.AppendLine("| " + kv.Key + ": " + kv.Value);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
sb.AppendLine("| ---- 请求头: (无 AddHeader 调用) ----");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (plain != null)
|
|
||||||
{
|
|
||||||
sb.AppendLine("| ---- 请求体(加密前明文)----");
|
|
||||||
foreach (var ln in Wrap(plain, 150))
|
|
||||||
sb.AppendLine("| " + ln);
|
|
||||||
}
|
|
||||||
Line(sb.ToString());
|
|
||||||
|
|
||||||
// 落盘原始明文
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string safe = Sanitize(_currentApi ?? ("req" + _reqCount));
|
|
||||||
File.WriteAllText(
|
|
||||||
Path.Combine(_outDir, "raw", _reqCount.ToString("D3") + "_" + safe + "_request.json"),
|
|
||||||
plain ?? "", new UTF8Encoding(false));
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
// 清空累积状态(本次请求已消费)
|
|
||||||
_headers = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PostRequest()
|
|
||||||
{
|
|
||||||
// 状态清理交给下一次 PreRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// ★ 真实 HTTP 请求头 dump
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// 为什么必须用 postfix:
|
|
||||||
// PreRequest 跑在 Request() 方法体**之前**,那时
|
|
||||||
// Method / ContentType / UserAgent / Content-Encoding 都还没设置。
|
|
||||||
// 只有 postfix 才能看到最终真正要发出去的头(含框架自带的)。
|
|
||||||
//
|
|
||||||
// 目的:把真机的**完整请求头**抓下来,和纯 Python 脚本发的做逐字节对比。
|
|
||||||
// (排查「报文一致但服务端 returnCode=0」这类问题)
|
|
||||||
private static void PostRequestDumpHeaders(Net.NetHttpClient __instance)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var fld = AccessTools.Field(typeof(Net.NetHttpClient), "_request");
|
|
||||||
var wr = (fld == null ? null : fld.GetValue(__instance)) as System.Net.HttpWebRequest;
|
|
||||||
if (wr == null) { Line("| [头 dump] _request 取不到"); return; }
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("| ---- ★ 真实 HTTP 请求头(HttpWebRequest,含框架自带)----");
|
|
||||||
sb.AppendLine("| [Method] : " + wr.Method);
|
|
||||||
sb.AppendLine("| [ContentType] : " + wr.ContentType);
|
|
||||||
sb.AppendLine("| [UserAgent] : " + wr.UserAgent);
|
|
||||||
sb.AppendLine("| [ContentLength] : " + wr.ContentLength);
|
|
||||||
sb.AppendLine("| [KeepAlive] : " + wr.KeepAlive);
|
|
||||||
sb.AppendLine("| [Expect] : " + wr.Expect);
|
|
||||||
sb.AppendLine("| [AutomaticDecompression] : " + wr.AutomaticDecompression);
|
|
||||||
sb.AppendLine("| [AllowAutoRedirect] : " + wr.AllowAutoRedirect);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
foreach (string k in wr.Headers.AllKeys)
|
|
||||||
sb.AppendLine("| " + k + ": " + wr.Headers[k]);
|
|
||||||
}
|
|
||||||
catch (Exception e2) { sb.AppendLine("| [Headers 枚举失败] " + e2.Message); }
|
|
||||||
try
|
|
||||||
{
|
|
||||||
sb.AppendLine("| [Cookie 头] : "
|
|
||||||
+ (wr.CookieContainer == null ? "(null container)"
|
|
||||||
: "[" + wr.CookieContainer.GetCookieHeader(wr.RequestUri) + "]"));
|
|
||||||
}
|
|
||||||
catch (Exception e3) { sb.AppendLine("| [Cookie 取失败] " + e3.Message); }
|
|
||||||
Line(sb.ToString());
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Line("| [头 dump 失败] " + e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// GetResponse —— 解密后的响应
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static void PostGetResponse(Net.NetHttpClient __instance, ref MemoryStream __result)
|
|
||||||
{
|
|
||||||
_respCount++;
|
|
||||||
|
|
||||||
// ★ 响应头 dump(尤其看 UserLoginApi 的 Set-Cookie: JSESSIONID)
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var fldR = AccessTools.Field(typeof(Net.NetHttpClient), "_response");
|
|
||||||
var resp = (fldR == null ? null : fldR.GetValue(__instance)) as System.Net.HttpWebResponse;
|
|
||||||
if (resp != null)
|
|
||||||
{
|
|
||||||
var sh = new StringBuilder();
|
|
||||||
sh.AppendLine("| ---- ★ 真实 HTTP 响应头 ----");
|
|
||||||
sh.AppendLine("| [StatusCode] : " + (int)resp.StatusCode);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
foreach (string k in resp.Headers.AllKeys)
|
|
||||||
sh.AppendLine("| " + k + ": " + resp.Headers[k]);
|
|
||||||
}
|
|
||||||
catch (Exception e4) { sh.AppendLine("| [响应头枚举失败] " + e4.Message); }
|
|
||||||
Line(sh.ToString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception e5) { Line("| [响应头 dump 失败] " + e5.Message); }
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (__result == null) return;
|
|
||||||
long pos = __result.Position;
|
|
||||||
__result.Position = 0;
|
|
||||||
byte[] buf = new byte[__result.Length];
|
|
||||||
int read = __result.Read(buf, 0, buf.Length);
|
|
||||||
__result.Position = pos;
|
|
||||||
|
|
||||||
string txt = Encoding.UTF8.GetString(buf, 0, read);
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("| [" + _respCount + "] <<< 响应 "
|
|
||||||
+ DateTime.Now.ToString("HH:mm:ss.fff")
|
|
||||||
+ " (" + read + " 字节)");
|
|
||||||
foreach (var ln in Wrap(txt, 150))
|
|
||||||
sb.AppendLine("| " + ln);
|
|
||||||
Line(sb.ToString());
|
|
||||||
|
|
||||||
string safe = Sanitize(_currentApi ?? ("resp" + _respCount));
|
|
||||||
File.WriteAllText(
|
|
||||||
Path.Combine(_outDir, "raw", _respCount.ToString("D3") + "_" + safe + "_response.json"),
|
|
||||||
txt, new UTF8Encoding(false));
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Line("| [响应解析失败] " + e.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// GetUserAgent —— 从 UA 反推 API 名;同时记录 URL
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static void PostGetUserAgent(Net.INetQuery query, ref string __result)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_currentApi = query?.Api;
|
|
||||||
// UA 形如 "<md5>#<userId>"
|
|
||||||
Line(" [API] " + _currentApi + " UA=" + __result);
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// CalcSpecialNum —— playSpecial 的硬件标志位
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static void PostCalcSpecialNum(ref int __result)
|
|
||||||
{
|
|
||||||
Line(" [CalcSpecialNum] = " + __result
|
|
||||||
+ " (SpecialAP=" + SafeBool(() => Manager.GameManager.SpecialAP)
|
|
||||||
+ " IsFreedomMode=" + SafeBool(() => Manager.GameManager.IsFreedomMode)
|
|
||||||
+ ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string SafeBool(Func<bool> f)
|
|
||||||
{
|
|
||||||
try { return f().ToString(); } catch { return "?"; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// SetError —— 记录网络层错误(含 HTTP 状态码)
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static void PreSetError(System.Net.WebExceptionStatus status, string error,
|
|
||||||
int state, System.Net.HttpWebResponse response)
|
|
||||||
{
|
|
||||||
Line(" [NetError] status=" + status + " state=" + state
|
|
||||||
+ " error=" + error
|
|
||||||
+ (response != null ? (" http=" + (int)response.StatusCode) : ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// 工具
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
private static string Sanitize(string s)
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
foreach (char c in s)
|
|
||||||
sb.Append(char.IsLetterOrDigit(c) || c == '_' || c == '-' ? c : '_');
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IEnumerable<string> Wrap(string s, int width)
|
|
||||||
{
|
|
||||||
if (s == null) yield break;
|
|
||||||
for (int i = 0; i < s.Length; i += width)
|
|
||||||
yield return s.Substring(i, Math.Min(width, s.Length - i));
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static void Line(string s)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
try { _writer?.WriteLine(s); } catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-703
@@ -1,703 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
r"""
|
|
||||||
netdelivery.py —— ALL.Net DLI(Download Instruction):查 / 下 .app 与 .opt 更新包
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
【DLI 是什么】
|
|
||||||
DLI = **Download Instruction**(配信指示書)。
|
|
||||||
机台做完 ALL.Net 认证后,AMDaemon 会再问一次「我该下什么包」:
|
|
||||||
|
|
||||||
POST http://at.sys-allnet.cn/net/delivery/instruction
|
|
||||||
body = title_id=SDGB&title_ver=<版本>&client_id=<Keychip 短值>
|
|
||||||
|
|
||||||
服务器回:
|
|
||||||
|
|
||||||
result=1&uri=<patch 指示書 URL>|<option 指示書 URL>
|
|
||||||
|
|
||||||
指示書本身是个 INI(日文注释),真正要下的包写在 INSTALLn= 里:
|
|
||||||
|
|
||||||
[COMMON] INSTALL1=https://.../SDGB_1.56.00_..._1_1.55.01.app
|
|
||||||
[OPTIONAL] INSTALL1=https://.../SDGB_A031_..._0.opt
|
|
||||||
INSTALL2=https://.../SDGB_A005_..._0.opt
|
|
||||||
|
|
||||||
★ 只有 INSTALLn= 才是**要下载的包**;EXISTn= 是「本机应该已经有的文件」,
|
|
||||||
只是文件名,不是下载项。
|
|
||||||
|
|
||||||
【协议出处】—— source/amdaemon.exe 字符串(数字是文件内偏移)
|
|
||||||
端点 http://at.sys-all.cn/net/delivery/instruction @0x788198
|
|
||||||
库 alw_lite/src/system39/alw_api_netdeliver.c @0x63bf80
|
|
||||||
阶段 ALWE_NETDELIVER_PHASE_GET_DLINST_INFO @0x63bde0
|
|
||||||
ALWE_NETDELIVER_PHASE_APP_IMAGE_INFO @0x63be08
|
|
||||||
ALWE_NETDELIVER_PHASE_OPT_IMAGE_INFO @0x63be30
|
|
||||||
日志 "Net deliver param - dli uri : %s." @0x63c3f8
|
|
||||||
状态 ALE_ABEX_STAT_DLINSTR_OK / _ERR_DLINSTR_NG ... @0x638600
|
|
||||||
|
|
||||||
【实测结论】(2026-10-06)
|
|
||||||
title_ver=1.55 -> uri = patch(SDGB_patch_1.56...info) | option(SDGB_option_1.55...A005.info)
|
|
||||||
title_ver=1.56 -> uri = | option(A031) (patch 槽为空 = 本体已最新)
|
|
||||||
title_ver<=1.54 -> uri = null (服务器不认这个版本)
|
|
||||||
指示書里的 INSTALL 直链可以带 Range 断点续传(返回 206 + Content-Range)。
|
|
||||||
★ 已被下架的历史包,源站会回 504 —— 这是服务器状态,不是脚本的问题。
|
|
||||||
|
|
||||||
【用法】
|
|
||||||
python netdelivery.py # 用 config.py 里的 Keychip 查,然后**多选下载**
|
|
||||||
python netdelivery.py A63E01E9580 # 指定 Keychip
|
|
||||||
python netdelivery.py --list # 只列清单,不下载
|
|
||||||
python netdelivery.py --all # 全下(不交互)
|
|
||||||
python netdelivery.py --only 1,3 # 只下第 1、3 项(不交互)
|
|
||||||
python netdelivery.py --ver 1.55 # 指定上报版本(默认 config.MAI_ENCODING)
|
|
||||||
python netdelivery.py --out D:\SDGB2026\update
|
|
||||||
python netdelivery.py --insecure # 关掉 TLS 校验(CDN 证书有问题时用)
|
|
||||||
|
|
||||||
★ 交互选择支持:逗号/空格分隔、区间(如 1-3)、a=全选、n=全不选、q=退出。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import urllib3
|
|
||||||
|
|
||||||
from authv1 import (
|
|
||||||
DEFAULT_HOST,
|
|
||||||
DEFAULT_TIMEOUT,
|
|
||||||
AllnetError,
|
|
||||||
delivery_instruction,
|
|
||||||
parse_keychip,
|
|
||||||
)
|
|
||||||
|
|
||||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
||||||
|
|
||||||
# 下载器用的 UA(实测有效)
|
|
||||||
DOWNLOAD_HEADERS = {"User-Agent": "SDGB;Windows/Lite"}
|
|
||||||
|
|
||||||
DEFAULT_OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "download")
|
|
||||||
CHUNK = 1 << 16
|
|
||||||
PROBE_TIMEOUT = 30
|
|
||||||
DOWNLOAD_TIMEOUT = 60
|
|
||||||
|
|
||||||
# 包类型判定(按扩展名)
|
|
||||||
KIND_BY_EXT = {
|
|
||||||
".app": "app",
|
|
||||||
".opt": "opt",
|
|
||||||
".pack": "pack",
|
|
||||||
}
|
|
||||||
|
|
||||||
KIND_TEXT = {
|
|
||||||
"app": "本体/补丁",
|
|
||||||
"opt": "选项包",
|
|
||||||
"pack": "资源包",
|
|
||||||
"?": "未知",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# HTTP
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def http_get(url, *, headers=None, stream=False, timeout=PROBE_TIMEOUT, insecure=False):
|
|
||||||
"""
|
|
||||||
GET,顺带处理 CDN 证书问题。
|
|
||||||
|
|
||||||
先按正常校验发;如果因为证书失败,再自动退回 verify=False 重发一次
|
|
||||||
(只影响这一次请求,不改变全局设置)。
|
|
||||||
"""
|
|
||||||
merged = dict(DOWNLOAD_HEADERS)
|
|
||||||
if headers:
|
|
||||||
merged.update(headers)
|
|
||||||
try:
|
|
||||||
return requests.get(url, headers=merged, stream=stream, timeout=timeout,
|
|
||||||
verify=not insecure)
|
|
||||||
except requests.exceptions.SSLError:
|
|
||||||
if insecure:
|
|
||||||
raise
|
|
||||||
return requests.get(url, headers=merged, stream=stream, timeout=timeout,
|
|
||||||
verify=False)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 配信指示書解析
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def parse_instruction(text):
|
|
||||||
"""
|
|
||||||
解析配信指示書 INI。
|
|
||||||
|
|
||||||
返回 (common, installs):
|
|
||||||
common = {GAME_DESC: ..., DOWNLOAD_ID: ..., ...}([COMMON] 段)
|
|
||||||
installs = [{"section": "COMMON"/"OPTIONAL", "key": "INSTALL1", "url": ...}, ...]
|
|
||||||
"""
|
|
||||||
common = {}
|
|
||||||
installs = []
|
|
||||||
section = ""
|
|
||||||
for raw_line in text.splitlines():
|
|
||||||
line = raw_line.strip()
|
|
||||||
if not line or line.startswith(";") or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
if line.startswith("[") and line.endswith("]"):
|
|
||||||
section = line[1:-1].strip().upper()
|
|
||||||
continue
|
|
||||||
if "=" not in line:
|
|
||||||
continue
|
|
||||||
key, value = line.split("=", 1)
|
|
||||||
key = key.strip().upper()
|
|
||||||
value = value.strip().strip('"')
|
|
||||||
if section in ("", "COMMON"):
|
|
||||||
common.setdefault(key, value)
|
|
||||||
if key.startswith("INSTALL") and value:
|
|
||||||
installs.append({"section": section or "COMMON", "key": key, "url": value})
|
|
||||||
return common, installs
|
|
||||||
|
|
||||||
|
|
||||||
def guess_kind(url):
|
|
||||||
"""按扩展名猜包类型。"""
|
|
||||||
name = url.split("?")[0].rsplit("/", 1)[-1]
|
|
||||||
ext = os.path.splitext(name)[1].lower()
|
|
||||||
return KIND_BY_EXT.get(ext, "?")
|
|
||||||
|
|
||||||
|
|
||||||
def package_name(url):
|
|
||||||
return url.split("?")[0].rsplit("/", 1)[-1]
|
|
||||||
|
|
||||||
|
|
||||||
def human_size(num):
|
|
||||||
if num is None:
|
|
||||||
return "?"
|
|
||||||
value = float(num)
|
|
||||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
||||||
if value < 1024 or unit == "TB":
|
|
||||||
return ("%d B" % int(value)) if unit == "B" else ("%.2f %s" % (value, unit))
|
|
||||||
value /= 1024.0
|
|
||||||
return "?"
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 采集:DLI -> 指示書 -> 包清单
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def fetch_instruction_urls(client_id, *, title_ver, host, timeout):
|
|
||||||
"""问 DLI,返回 (patch_info_url, option_info_url, raw_text)。"""
|
|
||||||
text = delivery_instruction(client_id, title_ver=title_ver, host=host, timeout=timeout)
|
|
||||||
kv = {}
|
|
||||||
for part in text.strip().split("&"):
|
|
||||||
if "=" in part:
|
|
||||||
k, v = part.split("=", 1)
|
|
||||||
kv[k.strip()] = v
|
|
||||||
result = kv.get("result", "")
|
|
||||||
if result != "1":
|
|
||||||
raise AllnetError("DLI 返回 result=%s(原始:%s)" % (result, text.strip()[:200]))
|
|
||||||
|
|
||||||
uri = kv.get("uri", "").strip()
|
|
||||||
if uri in ("", "null"):
|
|
||||||
raise AllnetError("DLI 返回 uri=%s:这个 title_ver 没有配信指示書"
|
|
||||||
"(换 --ver 试试,例如 1.55)" % (uri or "(空)"))
|
|
||||||
|
|
||||||
parts = uri.split("|")
|
|
||||||
patch_url = parts[0].strip() if len(parts) > 0 else ""
|
|
||||||
option_url = parts[1].strip() if len(parts) > 1 else ""
|
|
||||||
for bad in ("null", "none"):
|
|
||||||
if patch_url.lower() == bad:
|
|
||||||
patch_url = ""
|
|
||||||
if option_url.lower() == bad:
|
|
||||||
option_url = ""
|
|
||||||
return patch_url, option_url, text.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def collect_packages(client_id, *, title_ver, host, timeout, insecure=False):
|
|
||||||
"""
|
|
||||||
走完整条链路,返回 (items, meta)。
|
|
||||||
|
|
||||||
items 每一项:
|
|
||||||
{"idx":1, "kind":"app", "section":"COMMON", "key":"INSTALL1",
|
|
||||||
"url":..., "name":..., "size":int|None, "note":"", "info_name":...}
|
|
||||||
"""
|
|
||||||
patch_url, option_url, dli_raw = fetch_instruction_urls(
|
|
||||||
client_id, title_ver=title_ver, host=host, timeout=timeout)
|
|
||||||
|
|
||||||
items = []
|
|
||||||
meta = {"dli_raw": dli_raw, "title_ver": title_ver, "client_id": client_id,
|
|
||||||
"instructions": [], "infos": {}}
|
|
||||||
|
|
||||||
for info_url in (patch_url, option_url):
|
|
||||||
if not info_url:
|
|
||||||
continue
|
|
||||||
info_name = package_name(info_url)
|
|
||||||
resp = None
|
|
||||||
for attempt in range(1, 4):
|
|
||||||
try:
|
|
||||||
resp = http_get(info_url, timeout=timeout, insecure=insecure)
|
|
||||||
if resp.status_code < 500:
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
resp = None
|
|
||||||
time.sleep(1.5 * attempt)
|
|
||||||
if resp is None:
|
|
||||||
meta["instructions"].append({"url": info_url,
|
|
||||||
"error": "拉取失败(已重试 3 次)"})
|
|
||||||
continue
|
|
||||||
if resp.status_code != 200:
|
|
||||||
meta["instructions"].append({"url": info_url, "status": resp.status_code,
|
|
||||||
"error": "HTTP %d" % resp.status_code})
|
|
||||||
continue
|
|
||||||
text = resp.content.decode("utf-8", "replace")
|
|
||||||
common, installs = parse_instruction(text)
|
|
||||||
meta["instructions"].append({"url": info_url, "status": 200,
|
|
||||||
"name": info_name, "common": common})
|
|
||||||
meta["infos"][info_name] = text
|
|
||||||
|
|
||||||
seen = set()
|
|
||||||
for entry in installs:
|
|
||||||
if entry["url"] in seen:
|
|
||||||
continue
|
|
||||||
seen.add(entry["url"])
|
|
||||||
items.append({
|
|
||||||
"kind": guess_kind(entry["url"]),
|
|
||||||
"section": entry["section"],
|
|
||||||
"key": entry["key"],
|
|
||||||
"url": entry["url"],
|
|
||||||
"name": package_name(entry["url"]),
|
|
||||||
"size": None,
|
|
||||||
"note": "",
|
|
||||||
"info_name": info_name,
|
|
||||||
})
|
|
||||||
|
|
||||||
for i, item in enumerate(items, 1):
|
|
||||||
item["idx"] = i
|
|
||||||
return items, meta
|
|
||||||
|
|
||||||
|
|
||||||
def probe_all(items, *, timeout=PROBE_TIMEOUT, insecure=False):
|
|
||||||
"""探每个包的大小 / 可用性(就地写回 item)。"""
|
|
||||||
for item in items:
|
|
||||||
for attempt in range(1, 3):
|
|
||||||
try:
|
|
||||||
resp = http_get(item["url"], headers={"Range": "bytes=0-0"},
|
|
||||||
stream=True, timeout=timeout, insecure=insecure)
|
|
||||||
status = resp.status_code
|
|
||||||
content_range = resp.headers.get("Content-Range", "")
|
|
||||||
content_length = resp.headers.get("Content-Length")
|
|
||||||
resp.close()
|
|
||||||
if status == 206 and "/" in content_range:
|
|
||||||
item["size"] = int(content_range.rsplit("/", 1)[-1])
|
|
||||||
item["note"] = ""
|
|
||||||
elif status == 200 and content_length and content_length.isdigit():
|
|
||||||
item["size"] = int(content_length)
|
|
||||||
item["note"] = ""
|
|
||||||
elif status in (404, 504):
|
|
||||||
item["note"] = "HTTP %d(源站不可用 / 该包已下架)" % status
|
|
||||||
else:
|
|
||||||
item["note"] = "HTTP %d" % status
|
|
||||||
break
|
|
||||||
except Exception as exc:
|
|
||||||
item["note"] = "探测失败:%s" % exc
|
|
||||||
if attempt < 2:
|
|
||||||
time.sleep(1.5)
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 展示与选择
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def print_packages(items):
|
|
||||||
if not items:
|
|
||||||
print(" (指示書里没有任何 INSTALL 项)")
|
|
||||||
return
|
|
||||||
print("")
|
|
||||||
print(" 序号 类型 大小 来源段/键 文件名")
|
|
||||||
print(" " + "-" * 92)
|
|
||||||
for item in items:
|
|
||||||
size = human_size(item["size"]) if item["size"] is not None else (item["note"] or "?")
|
|
||||||
print(" [%2d] %-4s %-12s %-20s %s" % (
|
|
||||||
item["idx"], item["kind"], size,
|
|
||||||
item["section"] + "/" + item["key"], item["name"]))
|
|
||||||
if item["note"] and item["size"] is None:
|
|
||||||
print(" %s" % item["note"])
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_selection(items):
|
|
||||||
"""
|
|
||||||
交互多选。返回选中的 0 基下标列表;用户 q 退出时返回 None。
|
|
||||||
"""
|
|
||||||
print("")
|
|
||||||
print(" 可多选:输入序号,用逗号或空格分隔;支持区间(如 1-3)。")
|
|
||||||
print(" a = 全选 n = 都不选 q = 退出")
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
raw = input(" 请选择要下载的包 > ").strip()
|
|
||||||
except (EOFError, KeyboardInterrupt):
|
|
||||||
print("")
|
|
||||||
return None
|
|
||||||
if not raw:
|
|
||||||
continue
|
|
||||||
low = raw.lower()
|
|
||||||
if low in ("q", "quit", "exit"):
|
|
||||||
return None
|
|
||||||
if low in ("a", "all"):
|
|
||||||
return list(range(len(items)))
|
|
||||||
if low in ("n", "none"):
|
|
||||||
return []
|
|
||||||
|
|
||||||
picked, bad = [], []
|
|
||||||
for token in re.split(r"[,,、\s]+", raw):
|
|
||||||
if not token:
|
|
||||||
continue
|
|
||||||
span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
|
|
||||||
if span:
|
|
||||||
low_i, high_i = sorted((int(span.group(1)), int(span.group(2))))
|
|
||||||
for i in range(low_i, high_i + 1):
|
|
||||||
if 1 <= i <= len(items):
|
|
||||||
picked.append(i - 1)
|
|
||||||
else:
|
|
||||||
bad.append(str(i))
|
|
||||||
continue
|
|
||||||
if token.isdigit():
|
|
||||||
i = int(token)
|
|
||||||
if 1 <= i <= len(items):
|
|
||||||
picked.append(i - 1)
|
|
||||||
else:
|
|
||||||
bad.append(token)
|
|
||||||
else:
|
|
||||||
bad.append(token)
|
|
||||||
|
|
||||||
if bad:
|
|
||||||
print(" [x] 无效输入:%s(范围是 1-%d)" % (", ".join(bad), len(items)))
|
|
||||||
continue
|
|
||||||
|
|
||||||
ordered, seen = [], set()
|
|
||||||
for i in picked:
|
|
||||||
if i not in seen:
|
|
||||||
seen.add(i)
|
|
||||||
ordered.append(i)
|
|
||||||
if not ordered:
|
|
||||||
print(" [x] 没有选中任何项")
|
|
||||||
continue
|
|
||||||
return ordered
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 下载
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def download_file(url, dest, *, timeout=DOWNLOAD_TIMEOUT, retries=3, insecure=False):
|
|
||||||
"""
|
|
||||||
带断点续传的下载。返回 (ok, 说明)。
|
|
||||||
|
|
||||||
★ 先写 dest + ".part",下完再改名 —— 中断了下次能接着下。
|
|
||||||
"""
|
|
||||||
part = dest + ".part"
|
|
||||||
total = None
|
|
||||||
|
|
||||||
for attempt in range(1, retries + 1):
|
|
||||||
done = os.path.getsize(part) if os.path.exists(part) else 0
|
|
||||||
headers = {}
|
|
||||||
if done:
|
|
||||||
headers["Range"] = "bytes=%d-" % done
|
|
||||||
try:
|
|
||||||
resp = http_get(url, headers=headers, stream=True, timeout=timeout,
|
|
||||||
insecure=insecure)
|
|
||||||
except Exception as exc:
|
|
||||||
if attempt == retries:
|
|
||||||
return False, "连接失败:%s" % exc
|
|
||||||
time.sleep(2)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if resp.status_code not in (200, 206):
|
|
||||||
resp.close()
|
|
||||||
if resp.status_code in (404, 504):
|
|
||||||
return False, "HTTP %d(源站不可用 / 该包已下架)" % resp.status_code
|
|
||||||
if attempt == retries:
|
|
||||||
return False, "HTTP %d" % resp.status_code
|
|
||||||
time.sleep(2)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 服务器忽略 Range 直接给了全量 -> 从头写
|
|
||||||
if done and resp.status_code == 200:
|
|
||||||
done = 0
|
|
||||||
|
|
||||||
content_range = resp.headers.get("Content-Range", "")
|
|
||||||
if "/" in content_range:
|
|
||||||
try:
|
|
||||||
total = int(content_range.rsplit("/", 1)[-1])
|
|
||||||
except ValueError:
|
|
||||||
total = None
|
|
||||||
elif resp.headers.get("Content-Length", "").isdigit():
|
|
||||||
total = done + int(resp.headers["Content-Length"])
|
|
||||||
|
|
||||||
mode = "ab" if done else "wb"
|
|
||||||
started = time.time()
|
|
||||||
written = done
|
|
||||||
last_tick = 0.0
|
|
||||||
last_shown = -1
|
|
||||||
try:
|
|
||||||
with open(part, mode) as fp:
|
|
||||||
for chunk in resp.iter_content(CHUNK):
|
|
||||||
if not chunk:
|
|
||||||
continue
|
|
||||||
fp.write(chunk)
|
|
||||||
written += len(chunk)
|
|
||||||
now = time.time()
|
|
||||||
if now - last_tick >= 0.3:
|
|
||||||
last_tick = now
|
|
||||||
last_shown = written
|
|
||||||
show_progress(written, total, now - started)
|
|
||||||
except Exception as exc:
|
|
||||||
resp.close()
|
|
||||||
if attempt == retries:
|
|
||||||
return False, "写入中断:%s" % exc
|
|
||||||
time.sleep(2)
|
|
||||||
continue
|
|
||||||
finally:
|
|
||||||
resp.close()
|
|
||||||
|
|
||||||
if written == last_shown:
|
|
||||||
sys.stdout.write("\n") # 收尾换行,避免重复打一行
|
|
||||||
else:
|
|
||||||
show_progress(written, total, max(time.time() - started, 0.001), final=True)
|
|
||||||
|
|
||||||
if total is not None and written != total:
|
|
||||||
if attempt == retries:
|
|
||||||
return False, "长度不符:期望 %d 字节,实际 %d 字节" % (total, written)
|
|
||||||
time.sleep(2)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if os.path.exists(dest):
|
|
||||||
os.remove(dest)
|
|
||||||
os.replace(part, dest)
|
|
||||||
return True, "%s(%s)" % (human_size(written), dest)
|
|
||||||
|
|
||||||
return False, "重试 %d 次仍失败" % retries
|
|
||||||
|
|
||||||
|
|
||||||
def show_progress(done, total, elapsed, final=False):
|
|
||||||
speed = done / elapsed if elapsed > 0 else 0
|
|
||||||
if total:
|
|
||||||
percent = done * 100.0 / total
|
|
||||||
line = " %6.2f%% %s / %s %s/s" % (percent, human_size(done),
|
|
||||||
human_size(total), human_size(speed))
|
|
||||||
else:
|
|
||||||
line = " %s %s/s" % (human_size(done), human_size(speed))
|
|
||||||
sys.stdout.write("\r" + line + ("\n" if final else ""))
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# CLI
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def build_parser():
|
|
||||||
p = argparse.ArgumentParser(
|
|
||||||
prog="netdelivery.py",
|
|
||||||
description="ALL.Net DLI:查 / 下 .app 与 .opt 更新包",
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog=(
|
|
||||||
"示例:\n"
|
|
||||||
" python netdelivery.py # 查完让你多选\n"
|
|
||||||
" python netdelivery.py --list # 只列清单\n"
|
|
||||||
" python netdelivery.py --all # 全下\n"
|
|
||||||
" python netdelivery.py --only 1,3 # 只下第 1、3 项\n"
|
|
||||||
" python netdelivery.py --ver 1.55 # 指定上报版本\n"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
p.add_argument("keychip", nargs="?",
|
|
||||||
help="Keychip(默认取 config.py 的 KEYCHIP_ID)")
|
|
||||||
p.add_argument("--ver", default=None,
|
|
||||||
help="上报给 ALL.Net 的版本号(默认取 config.MAI_ENCODING)")
|
|
||||||
p.add_argument("--host", default=DEFAULT_HOST,
|
|
||||||
help="ALL.Net 认证服务器(默认 %s)" % DEFAULT_HOST)
|
|
||||||
p.add_argument("--out", default=DEFAULT_OUT_DIR,
|
|
||||||
help="下载目录(默认 <脚本目录>/download)")
|
|
||||||
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="接口超时秒数")
|
|
||||||
p.add_argument("--list", action="store_true", help="只列清单与直链,不下载")
|
|
||||||
p.add_argument("--all", action="store_true", help="全选(不交互)")
|
|
||||||
p.add_argument("--only", default=None, help="只下指定序号,如 1,3 或 1-3(不交互)")
|
|
||||||
p.add_argument("--no-probe", action="store_true", help="跳过包大小探测(更快)")
|
|
||||||
p.add_argument("--insecure", action="store_true", help="关掉 TLS 校验")
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_keychip(cli_value):
|
|
||||||
if cli_value:
|
|
||||||
return cli_value
|
|
||||||
try:
|
|
||||||
import config # noqa: F401
|
|
||||||
value = getattr(config, "KEYCHIP_ID", "")
|
|
||||||
if value:
|
|
||||||
return value
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_title_ver(cli_value):
|
|
||||||
if cli_value:
|
|
||||||
return str(cli_value)
|
|
||||||
try:
|
|
||||||
import config # noqa: F401
|
|
||||||
return str(getattr(config, "MAI_ENCODING", "1.55"))
|
|
||||||
except Exception:
|
|
||||||
return "1.55"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_only(text, count):
|
|
||||||
"""把 --only 的文本解析成 0 基下标列表。"""
|
|
||||||
picked, seen = [], set()
|
|
||||||
for token in re.split(r"[,,、\s]+", text or ""):
|
|
||||||
if not token:
|
|
||||||
continue
|
|
||||||
span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
|
|
||||||
if span:
|
|
||||||
low_i, high_i = sorted((int(span.group(1)), int(span.group(2))))
|
|
||||||
candidates = range(low_i, high_i + 1)
|
|
||||||
elif token.isdigit():
|
|
||||||
candidates = [int(token)]
|
|
||||||
else:
|
|
||||||
raise ValueError("--only 里有无效项:%r" % token)
|
|
||||||
for i in candidates:
|
|
||||||
if not (1 <= i <= count):
|
|
||||||
raise ValueError("--only 序号 %d 超出范围(1-%d)" % (i, count))
|
|
||||||
if i - 1 not in seen:
|
|
||||||
seen.add(i - 1)
|
|
||||||
picked.append(i - 1)
|
|
||||||
return picked
|
|
||||||
|
|
||||||
|
|
||||||
def save_instructions(meta, out_dir):
|
|
||||||
"""把配信指示書原样存一份,便于事后核对。"""
|
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
|
||||||
saved = []
|
|
||||||
for name, text in meta["infos"].items():
|
|
||||||
path = os.path.join(out_dir, name)
|
|
||||||
with open(path, "w", encoding="utf-8", newline="") as fp:
|
|
||||||
fp.write(text)
|
|
||||||
saved.append(path)
|
|
||||||
return saved
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
|
||||||
try:
|
|
||||||
sys.stdout.reconfigure(errors="replace")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
args = build_parser().parse_args(argv)
|
|
||||||
title_ver = resolve_title_ver(args.ver)
|
|
||||||
|
|
||||||
raw_keychip = resolve_keychip(args.keychip)
|
|
||||||
if not raw_keychip:
|
|
||||||
print("错误:没给 Keychip,config.py 里的 KEYCHIP_ID 也是空的。")
|
|
||||||
print(" 先跑一次 python authv1.py <你的Keychip> 把 config.py 填好。")
|
|
||||||
return 2
|
|
||||||
try:
|
|
||||||
client_id = parse_keychip(raw_keychip)
|
|
||||||
except ValueError as exc:
|
|
||||||
print("Keychip 解析失败:%s" % exc)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
print("=== netdelivery (ALL.Net DLI) ===")
|
|
||||||
print(" clientId : %s" % client_id)
|
|
||||||
print(" title_ver : %s" % title_ver)
|
|
||||||
print(" server : %s" % args.host)
|
|
||||||
print(" 正在查询配信指示書 ...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
items, meta = collect_packages(client_id, title_ver=title_ver, host=args.host,
|
|
||||||
timeout=args.timeout, insecure=args.insecure)
|
|
||||||
except (AllnetError, ValueError) as exc:
|
|
||||||
print("查询失败:%s" % exc)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
for entry in meta["instructions"]:
|
|
||||||
if entry.get("status") == 200:
|
|
||||||
common = entry.get("common", {})
|
|
||||||
print(" 指示書 %s" % entry["name"])
|
|
||||||
for key in ("GAME_DESC", "DLFORMAT", "GAME_ID", "DOWNLOAD_ID",
|
|
||||||
"RELEASE_TIME", "RELEASE_TYPE"):
|
|
||||||
if key in common:
|
|
||||||
print(" %-14s %s" % (key + ":", common[key]))
|
|
||||||
else:
|
|
||||||
print(" 指示書 %s -> %s" % (entry["url"], entry.get("error", "?")))
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
print("没有可下载的包。")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if not args.no_probe:
|
|
||||||
print(" 正在探测包大小 ...")
|
|
||||||
probe_all(items, insecure=args.insecure)
|
|
||||||
|
|
||||||
print_packages(items)
|
|
||||||
|
|
||||||
if args.list:
|
|
||||||
print("")
|
|
||||||
print(" 直链:")
|
|
||||||
for item in items:
|
|
||||||
print(" [%2d] %s" % (item["idx"], item["url"]))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# ---- 决定下哪些 ----
|
|
||||||
if args.only:
|
|
||||||
try:
|
|
||||||
chosen = parse_only(args.only, len(items))
|
|
||||||
except ValueError as exc:
|
|
||||||
print("参数错误:%s" % exc)
|
|
||||||
return 2
|
|
||||||
elif args.all:
|
|
||||||
chosen = list(range(len(items)))
|
|
||||||
elif not sys.stdin or not sys.stdin.isatty():
|
|
||||||
print("")
|
|
||||||
print(" 当前不是交互终端,未指定 --all / --only,已只列清单。")
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
chosen = prompt_selection(items)
|
|
||||||
if chosen is None:
|
|
||||||
print(" 已取消。")
|
|
||||||
return 0
|
|
||||||
if not chosen:
|
|
||||||
print(" 没有选中任何项,退出。")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
out_dir = args.out
|
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
|
||||||
for path in save_instructions(meta, out_dir):
|
|
||||||
print(" [指示書] %s" % path)
|
|
||||||
|
|
||||||
print("")
|
|
||||||
print(" 下载目录:%s" % out_dir)
|
|
||||||
failed = []
|
|
||||||
for index in chosen:
|
|
||||||
item = items[index]
|
|
||||||
dest = os.path.join(out_dir, item["name"])
|
|
||||||
print("")
|
|
||||||
print(" [%d/%d] %s(%s)" % (chosen.index(index) + 1, len(chosen),
|
|
||||||
item["name"], KIND_TEXT.get(item["kind"], "?")))
|
|
||||||
if item["size"] is None and item["note"]:
|
|
||||||
print(" 跳过:%s" % item["note"])
|
|
||||||
failed.append((item, item["note"]))
|
|
||||||
continue
|
|
||||||
started = time.time()
|
|
||||||
ok, message = download_file(item["url"], dest, insecure=args.insecure)
|
|
||||||
if ok:
|
|
||||||
print(" 完成,用时 %.1f 秒 -> %s" % (time.time() - started, message))
|
|
||||||
else:
|
|
||||||
print(" 失败:%s" % message)
|
|
||||||
failed.append((item, message))
|
|
||||||
|
|
||||||
print("")
|
|
||||||
if failed:
|
|
||||||
print(" --- 结束:%d 个成功,%d 个失败 ---" % (len(chosen) - len(failed), len(failed)))
|
|
||||||
for item, message in failed:
|
|
||||||
print(" [%2d] %s -> %s" % (item["idx"], item["name"], message))
|
|
||||||
return 1
|
|
||||||
print(" --- 结束:%d 个全部成功 ---" % len(chosen))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
-512
@@ -1,512 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
reincarnate.py —— 账号数据迁移(转生):把账号 A 的数据克隆到账号 B
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
┌─ 源账号 A ─────────────────────────────────────────────┐
|
|
||||||
│ 二维码 -> ParseQRCode -> UserLogin -> GetNeedUserData │
|
|
||||||
│ 或:直接读 data/<userId>.json(离线复用) │
|
|
||||||
└────────────────────────────────────────────────────────┘
|
|
||||||
↓ 节点数据
|
|
||||||
┌─ 目标账号 B ───────────────────────────────────────────┐
|
|
||||||
│ 二维码 -> ParseQRCode -> UserPreview -> UserLogin │
|
|
||||||
│ 循环: │
|
|
||||||
│ 等待 -> UpsertUserAll(一包曲目) │
|
|
||||||
│ -> UserLogout(type=1) │
|
|
||||||
│ -> UserLogin(isContinue=true) 续关,拿新 loginId │
|
|
||||||
│ 收尾:最后一包登出后结束(不再续关) │
|
|
||||||
└────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
★ 每包几首:1 / 3 / 4 可以,**恰好 2 首会被服务端拒绝**
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
真机一局(一个 credit)最多打 4 首,所以一条 UpsertUserAll 报文里的
|
|
||||||
userPlaylogList / userGamePlaylogList **最多 4 条**。
|
|
||||||
|
|
||||||
★ 但实测:**每包恰好 2 首会被服务端拒绝**(1 / 3 / 4 首都正常)。
|
|
||||||
所以本脚本的分批器会**主动避开 2 首包**:
|
|
||||||
|
|
||||||
10 首 -> [4, 4, 2] -> [4, 3, 3]
|
|
||||||
6 首 -> [4, 2] -> [3, 3]
|
|
||||||
2 首 -> [2] -> [1, 1]
|
|
||||||
|
|
||||||
★ 每包都必须带**非空**的 userPlaylogList —— 踩坑清单第 2 条实测:
|
|
||||||
留空会被服务端当成无效包**静默丢弃**(returnCode=1 但不落库)。
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
★ 为什么需要「续关登录」
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
UpsertUserAll 的 playlogId 必须等于**本次登录**的 loginId
|
|
||||||
(PacketUpsertUserAll.cs:31 / UserAllRequestVO)。
|
|
||||||
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 ——
|
|
||||||
真机走的就是「续关」:
|
|
||||||
|
|
||||||
Process/ContinueProcess.cs:403
|
|
||||||
new PacketUserLogin(userID, accessCode, isContinue: true, ...)
|
|
||||||
|
|
||||||
对应本工程:UserLogin(user_id, token, is_continue=True)
|
|
||||||
它和普通登录的唯一区别就是 UserLoginRequestVO.isContinue = true,
|
|
||||||
会返回**新的 loginId / loginDateTime**,供下一局 UpsertUserAll 使用。
|
|
||||||
|
|
||||||
★ 登出必须用 **type=1(LogoutType.Logout)**。
|
|
||||||
真机抓包实证 UserLogoutApi 就是 type=1(带 delayLog):
|
|
||||||
{"userId":...,"type":1,"delayLog":{...}}
|
|
||||||
续关场景下用 4(TestIn)会出问题 —— 那是「测试登入 / 顶号」的语义。
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
★ playlog 从哪来
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
GetNeedUserData 抓不到 userPlaylogList(那是逐局记录,没有对应的 Get 接口),
|
|
||||||
所以本脚本**从曲目记录反推合成**:
|
|
||||||
|
|
||||||
UserMusicDetail(9 字段) -> UserPlaylog(110 字段)
|
|
||||||
───────────────────────────── ─────────────────────────
|
|
||||||
musicId musicId
|
|
||||||
level level
|
|
||||||
achievement achievement
|
|
||||||
deluxscoreMax deluxscore
|
|
||||||
scoreRank scoreRank / isClear
|
|
||||||
comboStatus comboStatus
|
|
||||||
syncStatus syncStatus
|
|
||||||
其余字段 取自 config.PLAY_*(合理默认值)
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
用法
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
# 源数据来自二维码(会联网抓 A 的数据,并落盘到 data/<A>.json)
|
|
||||||
python reincarnate.py --dst-qr <B的二维码> --src-qr <A的二维码>
|
|
||||||
|
|
||||||
# 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 A 的二维码)
|
|
||||||
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json
|
|
||||||
|
|
||||||
# ★ 真实上传
|
|
||||||
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --send
|
|
||||||
|
|
||||||
# 只看计划,不发包
|
|
||||||
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --plan
|
|
||||||
|
|
||||||
# 常用开关
|
|
||||||
--limit N 只迁前 N 首(试跑用)
|
|
||||||
--per-packet N 每包曲目数,只能是 1/3/4(默认 4;★ 2 首会被服务端拒绝)
|
|
||||||
--wait N 每次上传前的等待秒数(默认 5.0,0 = 不等)
|
|
||||||
--clone-identity 连 userName / accessCode 一起克隆(默认**保留 B 自己的**)
|
|
||||||
--no-continue 不续关(只发一包,调试用)
|
|
||||||
|
|
||||||
★ 迁移会**真实写入**账号 B(不可撤销)。不传 --send 时只构造报文。
|
|
||||||
★ 二维码**有效期 10 分钟**。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
from api import (
|
|
||||||
setup_logging,
|
|
||||||
teardown_logging,
|
|
||||||
log_error,
|
|
||||||
log_info,
|
|
||||||
log_warning,
|
|
||||||
ParseQRCode,
|
|
||||||
GetUserPreview,
|
|
||||||
UserLogin,
|
|
||||||
UserLogout,
|
|
||||||
LogoutType,
|
|
||||||
GetNeedUserData,
|
|
||||||
normalize_snapshot,
|
|
||||||
NODE_ORDER,
|
|
||||||
NODE_TYPES,
|
|
||||||
ARRAY_TO_BITS,
|
|
||||||
DIFF_PRIMARY_KEYS,
|
|
||||||
PlayData,
|
|
||||||
build_user_playlog,
|
|
||||||
build_user_game_playlog,
|
|
||||||
UpsertUserAll,
|
|
||||||
ConfirmRequiredError,
|
|
||||||
Ping,
|
|
||||||
load_user_data,
|
|
||||||
list_saved_user_ids,
|
|
||||||
DATA_DIR,
|
|
||||||
)
|
|
||||||
from config import PLACE_ID, REGION_ID
|
|
||||||
|
|
||||||
#: 真机一局最多 4 首
|
|
||||||
MAX_TRACKS_PER_CREDIT = 4
|
|
||||||
|
|
||||||
#: ★ 被服务端禁止的每包曲目数(实测:**恰好 2 首会被拒**)
|
|
||||||
#: 1 / 3 / 4 首都正常,只有 2 首不行 —— 所以分批时要避开它。
|
|
||||||
FORBIDDEN_TRACK_COUNTS = (2,)
|
|
||||||
|
|
||||||
#: 允许的 --per-packet 取值
|
|
||||||
ALLOWED_PER_PACKET = (1, 3, 4)
|
|
||||||
|
|
||||||
|
|
||||||
def split_batches(total, per):
|
|
||||||
"""
|
|
||||||
把 total 首切成「每包 <= per」的批次,**并且不产生被禁止的 2 首包**。
|
|
||||||
|
|
||||||
做法:先贪心按 per 切,再修正大小为 2 的批次:
|
|
||||||
|
|
||||||
① 前一批 >= 4 -> 从它借 1 首过来(前一批变 >=3,本批变 3)
|
|
||||||
② 借不到(前一批只有 3,或自己就是第一批)-> 把这个 2 拆成 1 + 1
|
|
||||||
|
|
||||||
★ 只借位是不够的 —— 前一批是 3 时借完自己变 2,等于把 2 挪了个位置。
|
|
||||||
|
|
||||||
例:
|
|
||||||
10 首 per=4 -> [4, 4, 2] -> [4, 3, 3]
|
|
||||||
6 首 per=4 -> [4, 2] -> [3, 3]
|
|
||||||
2 首 per=4 -> [2] -> [1, 1]
|
|
||||||
5 首 per=3 -> [3, 2] -> [3, 1, 1]
|
|
||||||
8 首 per=3 -> [3, 3, 2] -> [3, 3, 1, 1]
|
|
||||||
5 首 per=4 -> [4, 1] (本来就合法,不动)
|
|
||||||
"""
|
|
||||||
if total <= 0:
|
|
||||||
return []
|
|
||||||
per = max(1, int(per))
|
|
||||||
out = []
|
|
||||||
left = total
|
|
||||||
while left > 0:
|
|
||||||
n = min(per, left)
|
|
||||||
out.append(n)
|
|
||||||
left -= n
|
|
||||||
|
|
||||||
i = 0
|
|
||||||
while i < len(out):
|
|
||||||
if out[i] not in FORBIDDEN_TRACK_COUNTS:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
# ① 前一批 >= 4 才能借(借完前一批 >= 3,本批 = 3)
|
|
||||||
if i > 0 and out[i - 1] >= 4:
|
|
||||||
out[i - 1] -= 1
|
|
||||||
out[i] += 1
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
# ② 借不到 -> 把这个 2 拆成 1 + 1
|
|
||||||
out[i] = 1
|
|
||||||
out.insert(i + 1, 1)
|
|
||||||
i += 2
|
|
||||||
return out
|
|
||||||
|
|
||||||
#: 默认保留目标账号自己的身份字段(不克隆过去,避免把 B 改名)
|
|
||||||
IDENTITY_KEYS = ("userName", "accessCode")
|
|
||||||
|
|
||||||
#: 迁移时**不**逐条传的节点(要么是位串、要么由 UpsertUserAll 自己算)
|
|
||||||
SKIP_NODES = (
|
|
||||||
"userMusicDetailList", # 单独分批
|
|
||||||
"userGamePlaylogList", # 由本脚本按曲目合成
|
|
||||||
"user2pPlaylog", # 2P 记录,迁移无意义
|
|
||||||
"userGetPointList", # 里程/奖励,迁移会凭空发点
|
|
||||||
"userTradeItemList", # 无 Get 来源
|
|
||||||
"userFriendSeasonRankingList", # 无 Get 来源
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv):
|
|
||||||
opts = {"dst_qr": "", "src_qr": "", "src_json": "", "send": False,
|
|
||||||
"plan": False, "debug": False, "limit": 0,
|
|
||||||
"per_packet": MAX_TRACKS_PER_CREDIT,
|
|
||||||
"clone_identity": False, "no_continue": False,
|
|
||||||
"wait": 5.0}
|
|
||||||
i = 0
|
|
||||||
while i < len(argv):
|
|
||||||
a = argv[i]
|
|
||||||
if a == "--send":
|
|
||||||
opts["send"] = True
|
|
||||||
elif a == "--plan":
|
|
||||||
opts["plan"] = True
|
|
||||||
elif a == "--debug":
|
|
||||||
opts["debug"] = True
|
|
||||||
elif a == "--clone-identity":
|
|
||||||
opts["clone_identity"] = True
|
|
||||||
elif a == "--no-continue":
|
|
||||||
opts["no_continue"] = True
|
|
||||||
elif a in ("--dst-qr", "--src-qr", "--src-json", "--limit",
|
|
||||||
"--per-packet", "--wait") and i + 1 < len(argv):
|
|
||||||
key = a[2:].replace("-", "_")
|
|
||||||
opts[key] = (float(argv[i + 1]) if key == "wait"
|
|
||||||
else int(argv[i + 1]) if key in ("limit", "per_packet")
|
|
||||||
else argv[i + 1])
|
|
||||||
i += 1
|
|
||||||
i += 1
|
|
||||||
return opts
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 工具
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def _bits_for(node, items, base_items):
|
|
||||||
"""
|
|
||||||
按目标账号 B 的基线算 isNew* 位串:
|
|
||||||
B 里没有该条目 -> "1"(新增)
|
|
||||||
B 里已有 -> "0"(更新)
|
|
||||||
主键表见 upsert_user_all.DIFF_PRIMARY_KEYS。
|
|
||||||
"""
|
|
||||||
keys = DIFF_PRIMARY_KEYS.get(node)
|
|
||||||
if not keys:
|
|
||||||
return None
|
|
||||||
have = set()
|
|
||||||
for b in (base_items or []):
|
|
||||||
if isinstance(b, dict):
|
|
||||||
have.add(tuple(str(b.get(k)) for k in keys))
|
|
||||||
return "".join(
|
|
||||||
"0" if tuple(str(it.get(k)) for k in keys) in have else "1"
|
|
||||||
for it in (items or []) if isinstance(it, dict)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def play_from_detail(detail, base_play=None):
|
|
||||||
"""
|
|
||||||
从一条 UserMusicDetail(9 字段)反推一份 PlayData,用来合成 userPlaylog。
|
|
||||||
|
|
||||||
对不上的字段保留 config.PLAY_* 的默认值(它们不在曲目记录里)。
|
|
||||||
"""
|
|
||||||
p = PlayData() if base_play is None else base_play
|
|
||||||
p.music_id = int(detail.get("musicId", p.music_id) or 0)
|
|
||||||
p.music_level = int(detail.get("level", p.music_level) or 0)
|
|
||||||
p.achievement = int(detail.get("achievement", p.achievement) or 0)
|
|
||||||
p.deluxscore = int(detail.get("deluxscoreMax", p.deluxscore) or 0)
|
|
||||||
rank = int(detail.get("scoreRank", 0) or 0)
|
|
||||||
p.score_rank = rank
|
|
||||||
p.is_clear = rank > 0
|
|
||||||
p.combo_status = int(detail.get("comboStatus", p.combo_status) or 0)
|
|
||||||
p.sync_status = int(detail.get("syncStatus", p.sync_status) or 0)
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
def collect_nodes(src_nodes, base_nodes, clone_identity):
|
|
||||||
"""把源账号 A 的节点整理成 extra_data(曲目/2P/奖励类除外)。"""
|
|
||||||
out = {}
|
|
||||||
for node in NODE_ORDER:
|
|
||||||
if node in SKIP_NODES:
|
|
||||||
continue
|
|
||||||
if node.startswith("isNew"):
|
|
||||||
continue # 位串单独算
|
|
||||||
kind = NODE_TYPES.get(node)
|
|
||||||
if kind not in ("singleton", "array", "object"):
|
|
||||||
continue
|
|
||||||
val = src_nodes.get(node)
|
|
||||||
if val in (None, [], {}):
|
|
||||||
continue
|
|
||||||
out[node] = json.loads(json.dumps(val)) # 深拷贝
|
|
||||||
# 身份字段:默认保留 B 自己的
|
|
||||||
if not clone_identity and out.get("userData"):
|
|
||||||
for item in out["userData"]:
|
|
||||||
if isinstance(item, dict):
|
|
||||||
for k in IDENTITY_KEYS:
|
|
||||||
item.pop(k, None)
|
|
||||||
# 位串:按 B 的基线算
|
|
||||||
for arr, bits in ARRAY_TO_BITS.items():
|
|
||||||
if arr not in out:
|
|
||||||
continue
|
|
||||||
b = _bits_for(arr, out[arr], base_nodes.get(arr))
|
|
||||||
if b:
|
|
||||||
out[bits] = b
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 主流程
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def fetch_source_by_qr(qr):
|
|
||||||
"""用源账号 A 的二维码联网抓一份数据(并自动落盘到 data/<A>.json)。"""
|
|
||||||
log_info("=== 源账号:联网抓取 ===")
|
|
||||||
uid, token = ParseQRCode(qr)
|
|
||||||
preview = GetUserPreview(uid, token)
|
|
||||||
if preview.isLogin:
|
|
||||||
raise RuntimeError("源账号处于已登录状态,请先在机台登出")
|
|
||||||
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
|
|
||||||
try:
|
|
||||||
raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
UserLogout(uid, token, login.loginDateTime, logout_type=LogoutType.TestIn,
|
|
||||||
region_id=REGION_ID, place_id=PLACE_ID)
|
|
||||||
except Exception as exc:
|
|
||||||
log_warning("源账号登出失败:" + str(exc))
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
opts = parse_args(sys.argv[1:])
|
|
||||||
if not opts["dst_qr"] or not (opts["src_qr"] or opts["src_json"]):
|
|
||||||
print(__doc__)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if opts["debug"]:
|
|
||||||
import config as _cfg
|
|
||||||
_cfg.LOG_LEVEL = "debug"
|
|
||||||
|
|
||||||
if opts["per_packet"] not in ALLOWED_PER_PACKET:
|
|
||||||
print("--per-packet 只能是 " + str(list(ALLOWED_PER_PACKET))
|
|
||||||
+ " 之一(真机一局最多 4 首;★ 实测恰好 2 首会被服务端拒绝)")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
setup_logging("reincarnate")
|
|
||||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
|
||||||
try:
|
|
||||||
# ---------------- 1. 源数据 ----------------
|
|
||||||
if opts["src_json"]:
|
|
||||||
src_raw = json.load(open(opts["src_json"], "r", encoding="utf-8"))
|
|
||||||
log_info("=== 源账号:从文件读取 " + opts["src_json"] + " ===")
|
|
||||||
else:
|
|
||||||
src_raw = fetch_source_by_qr(opts["src_qr"])
|
|
||||||
|
|
||||||
src_nodes = normalize_snapshot(src_raw)
|
|
||||||
musics = src_nodes.get("userMusicDetailList") or []
|
|
||||||
if opts["limit"] > 0:
|
|
||||||
musics = musics[:opts["limit"]]
|
|
||||||
log_info("源数据:节点 " + str(len(src_nodes)) + " 个,曲目记录 " + str(len(musics)) + " 条")
|
|
||||||
|
|
||||||
if not musics:
|
|
||||||
log_error("源数据里没有 userMusicDetailList,没什么可迁的。")
|
|
||||||
return 4
|
|
||||||
|
|
||||||
# ---------------- 2. 目标账号 ----------------
|
|
||||||
Ping()
|
|
||||||
uid, token = ParseQRCode(opts["dst_qr"])
|
|
||||||
session.update(user_id=uid, token=token)
|
|
||||||
preview = GetUserPreview(uid, token)
|
|
||||||
if preview.isLogin:
|
|
||||||
log_error("目标账号处于已登录状态,请先在机台登出或等待会话超时")
|
|
||||||
return 2
|
|
||||||
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
|
|
||||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
|
||||||
|
|
||||||
base_raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
|
|
||||||
base_nodes = normalize_snapshot(base_raw)
|
|
||||||
log_info("目标账号基线已抓取")
|
|
||||||
|
|
||||||
# ---------------- 3. 分批 ----------------
|
|
||||||
per = int(opts["per_packet"])
|
|
||||||
sizes = split_batches(len(musics), per)
|
|
||||||
batches = []
|
|
||||||
_pos = 0
|
|
||||||
for _n in sizes:
|
|
||||||
batches.append(musics[_pos:_pos + _n])
|
|
||||||
_pos += _n
|
|
||||||
log_info("计划:共 " + str(len(musics)) + " 首 -> " + str(len(batches))
|
|
||||||
+ " 包,每包 " + "/".join(str(n) for n in sizes[:12])
|
|
||||||
+ ("..." if len(sizes) > 12 else "") + " 首")
|
|
||||||
log_info(" ★ 已避开 2 首包(实测恰好 2 首会被服务端拒绝;1/3/4 首都正常)")
|
|
||||||
|
|
||||||
if opts["plan"]:
|
|
||||||
for i, b in enumerate(batches):
|
|
||||||
log_info(" 第 " + str(i + 1) + " 包(" + str(len(b)) + " 首):" + ", ".join(
|
|
||||||
str(x.get("musicId")) + "/L" + str(x.get("level")) for x in b))
|
|
||||||
log_info("--plan:只列计划,不发包")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# 第一包才带非曲目节点(整账号克隆)
|
|
||||||
shared_nodes = collect_nodes(src_nodes, base_nodes, opts["clone_identity"])
|
|
||||||
log_info("非曲目节点 " + str(len([k for k in shared_nodes if not k.startswith('isNew')]))
|
|
||||||
+ " 个将在第 1 包上传")
|
|
||||||
|
|
||||||
# ---------------- 4. 逐包上传 ----------------
|
|
||||||
# ★ 流程(按使用方口径):
|
|
||||||
# 登录 -> 等待 -> 上传 -> 登出(type=1) -> 续关登录 -> 等待 -> 上传 -> 登出 -> ...
|
|
||||||
#
|
|
||||||
# ★ 登出必须用 type=1(LogoutType.Logout)。
|
|
||||||
# 真机抓包实证 UserLogoutApi 就是 type=1(带 delayLog);
|
|
||||||
# 续关场景下用 4(TestIn)会出问题 —— 那是「测试登入/顶号」的语义。
|
|
||||||
sent = 0
|
|
||||||
for idx, batch in enumerate(batches):
|
|
||||||
log_info("")
|
|
||||||
log_info("========== 第 " + str(idx + 1) + "/" + str(len(batches))
|
|
||||||
+ " 包(" + str(len(batch)) + " 首)==========")
|
|
||||||
|
|
||||||
# ---- 等待 ----
|
|
||||||
if opts["wait"] > 0:
|
|
||||||
log_info("上传前等待 " + str(opts["wait"]) + " 秒 ...")
|
|
||||||
time.sleep(opts["wait"])
|
|
||||||
|
|
||||||
nodes = {}
|
|
||||||
if idx == 0:
|
|
||||||
nodes.update(json.loads(json.dumps(shared_nodes)))
|
|
||||||
nodes["userMusicDetailList"] = json.loads(json.dumps(batch))
|
|
||||||
b = _bits_for("userMusicDetailList", batch,
|
|
||||||
base_nodes.get("userMusicDetailList"))
|
|
||||||
if b:
|
|
||||||
nodes["isNewMusicDetailList"] = b
|
|
||||||
log_info(" isNewMusicDetailList = " + b)
|
|
||||||
|
|
||||||
# 合成 playlog(每首一条)与 game playlog
|
|
||||||
playlogs = []
|
|
||||||
game_playlogs = []
|
|
||||||
for d in batch:
|
|
||||||
p = play_from_detail(d)
|
|
||||||
playlogs.append(build_user_playlog(
|
|
||||||
login.loginId, login.loginDateTime, p, place_id=PLACE_ID))
|
|
||||||
game_playlogs.append(build_user_game_playlog(
|
|
||||||
login.loginId, login.loginDateTime, p))
|
|
||||||
nodes["userGamePlaylogList"] = game_playlogs
|
|
||||||
|
|
||||||
UpsertUserAll(
|
|
||||||
uid, login.loginId,
|
|
||||||
login_date_time=login.loginDateTime,
|
|
||||||
auth_date_time=login.dateTime,
|
|
||||||
token=token,
|
|
||||||
snapshot=base_raw,
|
|
||||||
auto_characters=False,
|
|
||||||
play=play_from_detail(batch[0]),
|
|
||||||
extra_data=nodes,
|
|
||||||
user_playlog_list=playlogs,
|
|
||||||
pre_new_item_list=False,
|
|
||||||
verify_landing=False,
|
|
||||||
dry_run=not opts["send"],
|
|
||||||
confirm=opts["send"],
|
|
||||||
place_id=PLACE_ID, region_id=REGION_ID,
|
|
||||||
)
|
|
||||||
sent += 1
|
|
||||||
|
|
||||||
# ---- ★ 登出(type=1 Logout)----
|
|
||||||
log_info("--- 登出(type=1 Logout)---")
|
|
||||||
try:
|
|
||||||
UserLogout(uid, token, login.loginDateTime,
|
|
||||||
logout_type=LogoutType.Logout,
|
|
||||||
region_id=REGION_ID, place_id=PLACE_ID)
|
|
||||||
except Exception as exc:
|
|
||||||
log_warning("登出失败(继续下一包):" + str(exc))
|
|
||||||
session["logged_in"] = False
|
|
||||||
|
|
||||||
# ---- ★ 续关登录:拿下一包的 loginId ----
|
|
||||||
if idx < len(batches) - 1:
|
|
||||||
if opts["no_continue"]:
|
|
||||||
log_warning("--no-continue:不再续关,提前结束(只发了 1 包)")
|
|
||||||
break
|
|
||||||
log_info("--- 续关登录(isContinue=true)---")
|
|
||||||
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID,
|
|
||||||
is_continue=True)
|
|
||||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
|
||||||
|
|
||||||
if not opts["send"]:
|
|
||||||
log_info("")
|
|
||||||
log_info("[dry-run] 未发包。想真实迁移请加 --send(★ 会写入账号 B)")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
log_info("")
|
|
||||||
log_info("★ 迁移完成:共上传 " + str(sent) + " 包 / "
|
|
||||||
+ str(min(len(musics), sent * per)) + " 首")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
except ConfirmRequiredError:
|
|
||||||
return 5
|
|
||||||
except Exception as exc:
|
|
||||||
log_error(type(exc).__name__ + ": " + str(exc))
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
if session["logged_in"]:
|
|
||||||
try:
|
|
||||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
|
||||||
logout_type=LogoutType.TestIn,
|
|
||||||
region_id=REGION_ID, place_id=PLACE_ID)
|
|
||||||
except Exception as exc:
|
|
||||||
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
|
|
||||||
teardown_logging()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
-1212
File diff suppressed because it is too large
Load Diff
@@ -1,239 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
upsert.py —— 主脚本:扫码登录 → 上传成绩 → 登出
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
整条流程只有四步,而且**一行日志都不用自己写** ——
|
|
||||||
每个 api 函数被调用时会自动打印:
|
|
||||||
|
|
||||||
=== UserLogin 开始运行 ===
|
|
||||||
[接口说明] 用户正式登录(开启本次登录会话)。
|
|
||||||
[位置参数] 12345678, 'iUfgRmjr...'
|
|
||||||
[关键字参数] region_id=1, place_id=1000
|
|
||||||
=== UserLogin 运行结束 ===
|
|
||||||
|
|
||||||
日志模式改 config.py 一行:
|
|
||||||
|
|
||||||
LOG_LEVEL = "info" 只展示 api 脚本的运行情况和结果
|
|
||||||
LOG_LEVEL = "debug" 额外展示向 api 发送的数据与 api 返回的数据
|
|
||||||
|
|
||||||
日志文件(两种同时写):
|
|
||||||
|
|
||||||
logs/latest.log 只含**当次**主脚本运行(下次覆盖)
|
|
||||||
logs/YYYY-MM-DD.log 存放**当日**产生的所有日志(追加)
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
用法
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
python upsert.py <二维码> # 预检:登录 + 抓快照 + 组包,不发包
|
|
||||||
python upsert.py <二维码> --send # 真实上传成绩(★ 有副作用,不可撤销)
|
|
||||||
python upsert.py <二维码> --send --debug # 同上,日志里带完整收发报文
|
|
||||||
python upsert.py <二维码> --send --cooldown 0 # 跳过发包前的 60 秒冷却
|
|
||||||
python upsert.py <二维码> --send --no-verify # 跳过上传后的落库回查
|
|
||||||
|
|
||||||
# ★ 额外塞节点(--extra-data,可重复,后给的覆盖先给的)
|
|
||||||
python upsert.py <二维码> --send --extra-data extra.json
|
|
||||||
python upsert.py <二维码> --send --extra-data '{"isNewMapList":"1"}'
|
|
||||||
python upsert.py <二维码> --send --extra-data a.json --extra-data b.json
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
★ UpsertUserAll 一个函数就把整条结算链路跑完了
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
1. 抓基线快照(没传 snapshot 时自动抓,用 token)
|
|
||||||
2. 从快照推本局 5 只角色(游玩前等级)
|
|
||||||
3. 构造本局成绩明细 userPlaylogList
|
|
||||||
4. 调前置接口 GetUserNewItemListApi
|
|
||||||
5. 组装 upsertUserAll 的 36 个节点(快照基线 + 本局演算 + 差分)
|
|
||||||
6. 合并 extra_data 里的外部 JSON
|
|
||||||
7. 发包前自检
|
|
||||||
8. 冷却 -> 真实发包
|
|
||||||
9. 落库回查(重新拉服务器数据,确认真的写进去了)
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
★ --extra-data:额外塞节点
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
想手工覆盖 / 补上某几个节点(比如本局拿到的新 KaleidxScope 门)时用:
|
|
||||||
|
|
||||||
# ① 从文件读(★ 推荐,不用跟 shell 引号搏斗)
|
|
||||||
python upsert.py <二维码> --send --extra-data extra.json
|
|
||||||
|
|
||||||
# ② 直接写 JSON 字符串
|
|
||||||
python upsert.py <二维码> --send --extra-data '{"isNewMapList":"1"}'
|
|
||||||
|
|
||||||
# ③ 多个来源,后给的覆盖先给的
|
|
||||||
python upsert.py <二维码> --send --extra-data base.json --extra-data patch.json
|
|
||||||
|
|
||||||
extra.json 长这样:
|
|
||||||
|
|
||||||
{
|
|
||||||
"userKaleidxScopeList": [
|
|
||||||
{"gateId": 1, "isGateFound": true, "isKeyFound": true, "isClear": false,
|
|
||||||
"totalRestLife": 0, "totalAchievement": 0, "totalDeluxscore": 0,
|
|
||||||
"bestAchievement": 0, "bestDeluxscore": 0,
|
|
||||||
"bestAchievementDate": "", "bestDeluxscoreDate": "",
|
|
||||||
"playCount": 0, "clearDate": "", "lastPlayDate": "", "isInfoWatched": false}
|
|
||||||
],
|
|
||||||
"isNewKaleidxScopeList": "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
★ 键必须是 upsertUserAll 的 36 个节点之一(含 11 条 isNew* 位串),
|
|
||||||
写错会明确报错并列出可用节点名。
|
|
||||||
★ 没给对应位串时会按数组长度自动补 "0" 并提示 ——
|
|
||||||
新增条目请显式传 "1","0" 表示存量更新。
|
|
||||||
★ JSON 在**扫码之前**就校验,写错了直接退出,不会浪费二维码。
|
|
||||||
|
|
||||||
── 在代码里直接调 api 的等价写法 ──
|
|
||||||
|
|
||||||
UpsertUserAll(
|
|
||||||
user_id, login.loginId,
|
|
||||||
login_date_time=login.loginDateTime,
|
|
||||||
auth_date_time=login.dateTime,
|
|
||||||
token=token,
|
|
||||||
extra_data={"userKaleidxScopeList": [...], "isNewKaleidxScopeList": "1"},
|
|
||||||
confirm=True,
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from api import (
|
|
||||||
setup_logging,
|
|
||||||
teardown_logging,
|
|
||||||
log_info,
|
|
||||||
log_warning,
|
|
||||||
log_error,
|
|
||||||
ParseQRCode,
|
|
||||||
GetUserPreview,
|
|
||||||
UserLogin,
|
|
||||||
UserLogout,
|
|
||||||
LogoutType,
|
|
||||||
UpsertUserAll,
|
|
||||||
ConfirmRequiredError,
|
|
||||||
load_extra_data,
|
|
||||||
PatchError,
|
|
||||||
NODE_ORDER,
|
|
||||||
NODE_TYPES,
|
|
||||||
)
|
|
||||||
from config import PLACE_ID, REGION_ID
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv):
|
|
||||||
"""极简命令行解析:第一个非 -- 开头的参数是二维码。"""
|
|
||||||
opts = {"qr": "", "send": False, "debug": False, "no_verify": False,
|
|
||||||
"cooldown": None, "extra_data": []}
|
|
||||||
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):
|
|
||||||
# 可重复;值是 JSON 字符串或 JSON 文件路径
|
|
||||||
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 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"]:
|
|
||||||
print(__doc__)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# --debug 只影响本次运行,不改 config.py
|
|
||||||
if opts["debug"]:
|
|
||||||
import config as _cfg
|
|
||||||
_cfg.LOG_LEVEL = "debug"
|
|
||||||
|
|
||||||
# 日志会话:开头一句。之后每次 api 调用都会自动记录,主脚本不用再写日志
|
|
||||||
setup_logging("upsert")
|
|
||||||
|
|
||||||
# ---- ★ 先解析 --extra-data(放在扫码之前:JSON 写错就别浪费二维码了)----
|
|
||||||
extra = None
|
|
||||||
if opts["extra_data"]:
|
|
||||||
merged = {}
|
|
||||||
try:
|
|
||||||
for item in opts["extra_data"]:
|
|
||||||
part = load_extra_data(item)
|
|
||||||
merged.update(part)
|
|
||||||
log_info("--extra-data 读入 " + str(len(part)) + " 个节点:" + ", ".join(part))
|
|
||||||
except PatchError as exc:
|
|
||||||
log_error("--extra-data 解析失败:" + str(exc))
|
|
||||||
return 6
|
|
||||||
|
|
||||||
# 节点名也在这里查(不然要等到登录后组包时才报错,白跑一趟)
|
|
||||||
unknown = [k for k in merged if k not in NODE_TYPES]
|
|
||||||
if unknown:
|
|
||||||
log_error("--extra-data 里有未知节点名:" + ", ".join(unknown))
|
|
||||||
log_error(" 可用节点(" + str(len(NODE_ORDER)) + " 个):" + ", ".join(NODE_ORDER))
|
|
||||||
return 6
|
|
||||||
|
|
||||||
if not merged:
|
|
||||||
log_warning("--extra-data 解析出来是空的,忽略")
|
|
||||||
else:
|
|
||||||
extra = merged
|
|
||||||
log_info("--extra-data 合计 " + str(len(extra)) + " 个节点:" + ", ".join(extra))
|
|
||||||
|
|
||||||
# 会话状态:保证异常时也能登出(否则账号会卡在 isLogin=True,下次登录 returnCode=100)
|
|
||||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
|
||||||
try:
|
|
||||||
# ---- 扫码:二维码 -> userId + token ----
|
|
||||||
user_id, token = ParseQRCode(opts["qr"])
|
|
||||||
session.update(user_id=user_id, token=token)
|
|
||||||
|
|
||||||
# ---- 预览:顺便挡住「账号已在游戏中」的情况,避免顶号 ----
|
|
||||||
preview = GetUserPreview(user_id, token)
|
|
||||||
if preview.isLogin:
|
|
||||||
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
|
|
||||||
return 2
|
|
||||||
|
|
||||||
# ---- 登录:拿到 playlogId 与 loginDateTime(结算上报要用)----
|
|
||||||
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
|
|
||||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
|
||||||
|
|
||||||
# ---- 结算:★ 一个函数搞定构造 + 发包 + 落库回查 ----
|
|
||||||
UpsertUserAll(
|
|
||||||
user_id, login.loginId,
|
|
||||||
login_date_time=login.loginDateTime,
|
|
||||||
auth_date_time=login.dateTime,
|
|
||||||
token=token,
|
|
||||||
extra_data=extra,
|
|
||||||
dry_run=not opts["send"],
|
|
||||||
confirm=opts["send"],
|
|
||||||
cooldown_sec=opts["cooldown"],
|
|
||||||
verify_landing=not opts["no_verify"],
|
|
||||||
place_id=PLACE_ID, region_id=REGION_ID,
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
except ConfirmRequiredError:
|
|
||||||
return 5
|
|
||||||
except Exception as exc:
|
|
||||||
log_error(type(exc).__name__ + ": " + str(exc))
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
# 兜底登出:登录过且还没登出就释放会话,否则账号卡在 isLogin=True
|
|
||||||
if session["logged_in"]:
|
|
||||||
try:
|
|
||||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
|
||||||
logout_type=LogoutType.TestIn,
|
|
||||||
region_id=REGION_ID, place_id=PLACE_ID)
|
|
||||||
except Exception as exc:
|
|
||||||
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
|
|
||||||
teardown_logging()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
-287
@@ -1,287 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
use_ticket.py —— 消耗账号当前持有的功能券(用 UpsertUserAll 表达「用票打了一局」)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
一条完整的「用票」链路:
|
|
||||||
|
|
||||||
ParseQRCode -> GetUserPreview -> UserLogin -> GetUserCharge
|
|
||||||
-> GetNeedUserData(基线快照)-> UpsertUserAll -> GetUserCharge(复查)-> UserLogout
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
★ 为什么可以用 UpsertUserAll 消耗票券
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
GetUserChargeApi 与 UpsertUserAllApi 里的 userChargeList **格式完全相同**
|
|
||||||
(UserCharge.cs:chargeId / stock / purchaseDate / validDate),
|
|
||||||
所以只要把 UpsertUserAll 的 userChargeList 节点改成「该票 stock - 1」,
|
|
||||||
服务端就会认为这张票被这一局用掉了。
|
|
||||||
|
|
||||||
真机实证(gamehook 抓包 2026-10-06 22:23 那一局,正好用了 2 倍券):
|
|
||||||
|
|
||||||
userChargeList: [
|
|
||||||
{"chargeId":2,"stock":0,...}, <- 买来时是 1,本局用掉变成 0
|
|
||||||
{"chargeId":3,"stock":0,...},
|
|
||||||
{"chargeId":5,"stock":0,...}
|
|
||||||
]
|
|
||||||
userGamePlaylogList[0].useTicketId = 2 <- ★ 用了哪张票
|
|
||||||
userGamePlaylogList[0].playCredit = 2 <- ★ 本局消耗的 credit
|
|
||||||
userPlaylogList[0].playCredit = 2
|
|
||||||
userData.lastPlayCredit = 2
|
|
||||||
|
|
||||||
★ playCredit 的算法(source/.../Net.VO.Mai2/VOExtensions.cs:838-845):
|
|
||||||
|
|
||||||
playCredit = Credit.GameCostEnoughPlay() // 投币数,普通局 = 1
|
|
||||||
+ TicketManager.GetTicketCredit(ticketId) // 票的 creditNum
|
|
||||||
|
|
||||||
2 倍券的 creditNum = 1 -> playCredit = 1 + 1 = 2 (与真机抓包一致)
|
|
||||||
creditNum 表见 TICKET_CREDIT_NUM。
|
|
||||||
|
|
||||||
★ 真机报文里**没有** isNewChargeList 这个节点(它不在 36 个节点里),
|
|
||||||
所以这里也不需要给位串。
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
用法
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
python use_ticket.py <二维码> # 预检:只看报文,不发包
|
|
||||||
python use_ticket.py <二维码> --list # 只列当前持有的票,不消耗
|
|
||||||
python use_ticket.py <二维码> --ticket-id 2 # 指定要消耗哪张(默认第一张持有的)
|
|
||||||
python use_ticket.py <二维码> --ticket-id 2 --send # ★ 真实消耗
|
|
||||||
python use_ticket.py <二维码> --send --music-id 143 --level 4
|
|
||||||
python use_ticket.py <二维码> --send --skip-entry # 跳过入场数据下载
|
|
||||||
|
|
||||||
★ 消耗会写入服务器(不可撤销)。不传 --send 时只构造报文。
|
|
||||||
★ 二维码**有效期 10 分钟**。
|
|
||||||
★ 账号当前**没有**持有功能票时直接拒绝(没有可消耗的东西)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from api import (
|
|
||||||
setup_logging,
|
|
||||||
teardown_logging,
|
|
||||||
log_error,
|
|
||||||
log_info,
|
|
||||||
log_warning,
|
|
||||||
ParseQRCode,
|
|
||||||
GetUserPreview,
|
|
||||||
UserLogin,
|
|
||||||
UserLogout,
|
|
||||||
LogoutType,
|
|
||||||
GetUserCharge,
|
|
||||||
GetNeedUserData,
|
|
||||||
summarize_snapshot,
|
|
||||||
UpsertUserAll,
|
|
||||||
PlayData,
|
|
||||||
ConfirmRequiredError,
|
|
||||||
ticket_name,
|
|
||||||
Ping,
|
|
||||||
# ★ 原先在 config.py,已下沉到 api/upsert_user_chargelog.py
|
|
||||||
TICKET_CREDIT_NUM,
|
|
||||||
)
|
|
||||||
from config import (
|
|
||||||
PLACE_ID,
|
|
||||||
REGION_ID,
|
|
||||||
PLAY_MUSIC_ID,
|
|
||||||
PLAY_MUSIC_LEVEL,
|
|
||||||
PLAY_CREDIT,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def read_charges(user_id, label):
|
|
||||||
"""调 GetUserChargeApi,打印票券状态并返回 (全部, 持有的)。"""
|
|
||||||
raw = GetUserCharge(user_id)
|
|
||||||
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
|
|
||||||
log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:")
|
|
||||||
if not lst:
|
|
||||||
log_info(" (空)")
|
|
||||||
for c in lst:
|
|
||||||
cid = c.get("chargeId")
|
|
||||||
stock = int(c.get("stock", 0) or 0)
|
|
||||||
log_info(" chargeId=" + str(cid) + " " + ticket_name(cid)
|
|
||||||
+ " stock=" + str(stock)
|
|
||||||
+ " purchaseDate=" + str(c.get("purchaseDate"))
|
|
||||||
+ " validDate=" + str(c.get("validDate"))
|
|
||||||
+ (" ← ★ 持有中" if stock > 0 else ""))
|
|
||||||
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
|
|
||||||
log_info("[" + label + "] 持有中的功能票:" + str(len(held)) + " 张")
|
|
||||||
return lst, held
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv):
|
|
||||||
opts = {"qr": "", "ticket_id": None, "send": False, "list": False,
|
|
||||||
"debug": False, "skip_entry": False,
|
|
||||||
"music_id": PLAY_MUSIC_ID, "level": PLAY_MUSIC_LEVEL}
|
|
||||||
i = 0
|
|
||||||
while i < len(argv):
|
|
||||||
a = argv[i]
|
|
||||||
if a == "--send":
|
|
||||||
opts["send"] = True
|
|
||||||
elif a == "--list":
|
|
||||||
opts["list"] = True
|
|
||||||
elif a == "--debug":
|
|
||||||
opts["debug"] = True
|
|
||||||
elif a == "--skip-entry":
|
|
||||||
opts["skip_entry"] = True
|
|
||||||
elif a == "--ticket-id" and i + 1 < len(argv):
|
|
||||||
opts["ticket_id"] = int(argv[i + 1])
|
|
||||||
i += 1
|
|
||||||
elif a == "--music-id" and i + 1 < len(argv):
|
|
||||||
opts["music_id"] = int(argv[i + 1])
|
|
||||||
i += 1
|
|
||||||
elif a == "--level" and i + 1 < len(argv):
|
|
||||||
opts["level"] = int(argv[i + 1])
|
|
||||||
i += 1
|
|
||||||
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"]:
|
|
||||||
print(__doc__)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if opts["debug"]:
|
|
||||||
import config as _cfg
|
|
||||||
_cfg.LOG_LEVEL = "debug"
|
|
||||||
|
|
||||||
setup_logging("use_ticket")
|
|
||||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
|
||||||
try:
|
|
||||||
# ---- 心跳:决定后续所有请求的 number 头 ----
|
|
||||||
Ping()
|
|
||||||
|
|
||||||
user_id, token = ParseQRCode(opts["qr"])
|
|
||||||
session.update(user_id=user_id, token=token)
|
|
||||||
|
|
||||||
preview = GetUserPreview(user_id, token)
|
|
||||||
if preview.isLogin:
|
|
||||||
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
|
|
||||||
return 2
|
|
||||||
|
|
||||||
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
|
|
||||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
|
||||||
|
|
||||||
# ---- ★ 读当前持有的功能券 ----
|
|
||||||
all_charges, held = read_charges(user_id, "消耗前")
|
|
||||||
|
|
||||||
if opts["list"]:
|
|
||||||
log_info("--list:只列票券,不做任何写操作")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if not held:
|
|
||||||
log_error("账号当前**没有持有任何功能票**,没有可消耗的东西。")
|
|
||||||
log_error(" 想买票请用 buy_ticket.py;已持有的票会在 userChargeList 里 stock > 0。")
|
|
||||||
return 4
|
|
||||||
|
|
||||||
# ---- 选定目标票 ----
|
|
||||||
target = None
|
|
||||||
if opts["ticket_id"] is not None:
|
|
||||||
for c in held:
|
|
||||||
if int(c.get("chargeId", -1)) == int(opts["ticket_id"]):
|
|
||||||
target = c
|
|
||||||
break
|
|
||||||
if target is None:
|
|
||||||
log_error("指定的 chargeId=" + str(opts["ticket_id"])
|
|
||||||
+ " 当前并不持有;持有的有:"
|
|
||||||
+ ", ".join(str(c.get("chargeId")) for c in held))
|
|
||||||
return 4
|
|
||||||
else:
|
|
||||||
target = held[0]
|
|
||||||
|
|
||||||
ticket_id = int(target.get("chargeId"))
|
|
||||||
credit_num = int(TICKET_CREDIT_NUM.get(ticket_id, 0) or 0)
|
|
||||||
play_credit = int(PLAY_CREDIT) + credit_num
|
|
||||||
|
|
||||||
log_info("选定要消耗的票:chargeId=" + str(ticket_id) + " " + ticket_name(ticket_id)
|
|
||||||
+ " stock=" + str(target.get("stock"))
|
|
||||||
+ " creditNum=" + str(credit_num)
|
|
||||||
+ " -> playCredit=" + str(PLAY_CREDIT) + "+" + str(credit_num)
|
|
||||||
+ "=" + str(play_credit))
|
|
||||||
|
|
||||||
# ---- ★ 构造新的 userChargeList:目标票 stock - 1,其余原样 ----
|
|
||||||
new_stock = int(target.get("stock", 0) or 0) - 1
|
|
||||||
if new_stock < 0:
|
|
||||||
new_stock = 0
|
|
||||||
new_list = []
|
|
||||||
for c in all_charges:
|
|
||||||
if not isinstance(c, dict):
|
|
||||||
continue
|
|
||||||
item = dict(c)
|
|
||||||
if int(item.get("chargeId", -1)) == ticket_id:
|
|
||||||
item["stock"] = new_stock
|
|
||||||
log_info(" userChargeList 里 chargeId=" + str(ticket_id)
|
|
||||||
+ " 的 stock:" + str(target.get("stock")) + " -> " + str(new_stock))
|
|
||||||
new_list.append(item)
|
|
||||||
|
|
||||||
# ---- 入场数据下载(贴近真机节奏,同时也是 UpsertUserAll 的基线快照)----
|
|
||||||
snapshot = None
|
|
||||||
if not opts["skip_entry"]:
|
|
||||||
snapshot = GetNeedUserData(user_id, token=token,
|
|
||||||
place_id=PLACE_ID, region_id=REGION_ID)
|
|
||||||
_sum = summarize_snapshot(snapshot)
|
|
||||||
log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 "
|
|
||||||
+ str(len(snapshot.get("_failed") or [])) + " 个")
|
|
||||||
|
|
||||||
# ---- ★ 用 UpsertUserAll 表达「用这张票打了一局」----
|
|
||||||
# play.use_ticket_id -> userGamePlaylogList[0].useTicketId
|
|
||||||
# play.play_credit -> userGamePlaylogList[0].playCredit / userPlaylogList[].playCredit
|
|
||||||
# / userData.lastPlayCredit
|
|
||||||
# extra_data.userChargeList -> 覆盖节点,把票的 stock 减 1
|
|
||||||
play = PlayData(
|
|
||||||
music_id=opts["music_id"],
|
|
||||||
music_level=opts["level"],
|
|
||||||
use_ticket_id=ticket_id,
|
|
||||||
play_credit=play_credit,
|
|
||||||
)
|
|
||||||
UpsertUserAll(
|
|
||||||
user_id, login.loginId,
|
|
||||||
login_date_time=login.loginDateTime,
|
|
||||||
auth_date_time=login.dateTime,
|
|
||||||
token=token,
|
|
||||||
snapshot=snapshot,
|
|
||||||
play=play,
|
|
||||||
extra_data={"userChargeList": new_list},
|
|
||||||
dry_run=not opts["send"],
|
|
||||||
confirm=opts["send"],
|
|
||||||
verify_landing=bool(opts["send"]),
|
|
||||||
place_id=PLACE_ID, region_id=REGION_ID,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---- ★ 复查:票券 stock 应该减 1 ----
|
|
||||||
_, held_after = read_charges(user_id, "消耗后")
|
|
||||||
if not opts["send"]:
|
|
||||||
log_info("[dry-run] 未发包。想真实消耗请加 --send(★ 会写入服务器)")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
still = [c for c in held_after if int(c.get("chargeId", -1)) == ticket_id]
|
|
||||||
if still:
|
|
||||||
log_warning("chargeId=" + str(ticket_id) + " 仍然持有(stock="
|
|
||||||
+ str(still[0].get("stock")) + ")—— 服务端可能没落库")
|
|
||||||
return 6
|
|
||||||
log_info("★ 票券已消耗:chargeId=" + str(ticket_id) + " 不再持有 ✅")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
except ConfirmRequiredError:
|
|
||||||
return 5
|
|
||||||
except Exception as exc:
|
|
||||||
log_error(type(exc).__name__ + ": " + str(exc))
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
if session["logged_in"]:
|
|
||||||
try:
|
|
||||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
|
||||||
logout_type=LogoutType.TestIn,
|
|
||||||
region_id=REGION_ID, place_id=PLACE_ID)
|
|
||||||
except Exception as exc:
|
|
||||||
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
|
|
||||||
teardown_logging()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
Reference in New Issue
Block a user