Files
Astral/docs/主脚本编写指南.md
T
Txiaochen 41909b9169 docs: WaHappy 开发文档
孤儿分支,只含 docs/。内容包括:
- 项目总览 / 主脚本编写指南 / 日志指南
- 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条)
- 真机 Hook 对照报告 / 实跑验证记录
- 购买功能票流程(含被服务端拒绝的排查过程)
  · 扫码登录协议技术规范
2026-10-06 14:42:32 +08:00

303 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 主脚本编写指南
> 面向「想用这个工程写自己业务流程」的人。
> 目标:**你只写业务,日志和协议细节都交给 `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` | 主脚本范例(极简版) |