# 主脚本编写指南 > 面向「想用这个工程写自己业务流程」的人。 > 目标:**你只写业务,日志和协议细节都交给 `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` | 主脚本范例(极简版) |