docs: WaHappy 开发文档
孤儿分支,只含 docs/。内容包括: - 项目总览 / 主脚本编写指南 / 日志指南 - 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条) - 真机 Hook 对照报告 / 实跑验证记录 - 购买功能票流程(含被服务端拒绝的排查过程) · 扫码登录协议技术规范
This commit is contained in:
+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` | 主脚本范例(极简版) |
|
||||
Reference in New Issue
Block a user