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

9.7 KiB
Raw Blame History

主脚本编写指南

面向「想用这个工程写自己业务流程」的人。 目标:你只写业务,日志和协议细节都交给 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。 主脚本里唯一必需的日志语句只有开头结尾那两句:

setup_logging("我的脚本")   # 开头
...
teardown_logging()          # 结尾(写在 finally 里)

2. 一定要保证登出

账号若停留在 isLogin=True,下次登录会返回 returnCode=100。 用 try/finally 兜底:

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 只看报文):

UpsertUserAll(..., dry_run=True)              # 只构造,不发
UpsertUserAll(..., dry_run=False, confirm=True)  # 真发

⚠ 只写 dry_run=False 不够 —— confirm=None 时会去读 config.UPSERT_CONFIRM(默认 False),照样被拦。


二、最小骨架

#!/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):

python upsert.py <二维码> --debug

★ DEBUG 会完整打印收发报文,一次结算流程能到 十几 MB。 只想看流程就别开;想瘦身调 config.LOG_VALUE_MAX。

3.2 两个日志文件(同时写)

文件 模式 内容
logs/latest.log 覆盖(w) 只含当次主脚本运行
logs/YYYY-MM-DD.log 追加(a) 当日全部日志

3.3 想记自己的话(可选)

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) —— 登出也不例外。

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 只登录(探活 / 查状态)

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 上传一局成绩(最常用)

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:

UpsertUserAll(..., extra_data={
    "userKaleidxScopeList": [{"gateId": 1, "isGateFound": True, ...}],
    "isNewKaleidxScopeList": "1",
})

5.3 买一张功能票

见 buy_ticket.py 与 docs/购买功能票流程.md。核心就一句:

UpsertUserChargelog(user_id, charge_id=2,
                    login_date_time=login.loginDateTime,
                    dry_run=False, confirm=True)

5.4 只取数(不发任何写接口)

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 主脚本范例(极简版)