docs: WaHappy 开发文档

孤儿分支,只含 docs/。内容包括:
- 项目总览 / 主脚本编写指南 / 日志指南
- 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条)
- 真机 Hook 对照报告 / 实跑验证记录
- 购买功能票流程(含被服务端拒绝的排查过程)
  · 扫码登录协议技术规范
This commit is contained in:
Txiaochen
2026-10-06 14:42:32 +08:00
commit 41909b9169
15 changed files with 34059 additions and 0 deletions
+302
View File
@@ -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` | 主脚本范例(极简版) |