孤儿分支,只含 docs/。内容包括: - 项目总览 / 主脚本编写指南 / 日志指南 - 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条) - 真机 Hook 对照报告 / 实跑验证记录 - 购买功能票流程(含被服务端拒绝的排查过程) · 扫码登录协议技术规范
303 lines
9.7 KiB
Markdown
303 lines
9.7 KiB
Markdown
# 主脚本编写指南
|
||
|
||
> 面向「想用这个工程写自己业务流程」的人。
|
||
> 目标:**你只写业务,日志和协议细节都交给 `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` | 主脚本范例(极简版) |
|