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

256 lines
12 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.
# 舞萌DX 纯网络脚本工程 · 项目总览
> 项目名 **WaHappy** —— 舞萌DX 的游戏助手。
> 面向使用者的说明见根目录 [`README.md`](../README.md);
> 本文件是**开发向的主文档**,开工前先读它;遇到「该放哪、该怎么写」的问题以本文件为准。
---
## 一、这个工程是什么
用 **Python 脚本**,通过**纯网络(纯 HTTP)** 形式直接与舞萌DX(SDGB 国服)的**游戏服务器**交互,
复刻游戏客户端的协议行为。
| 对象 | 是什么 | 对应反编译源码 |
|---|---|---|
| **游戏服务器** | 玩家数据读写:扫码登录、取数、上传成绩、登出 | `source/Assembly-CSharp/` |
| **纯网络形式** | 脚本**直接收发 HTTP 报文**(AES 加密 JSON),不依赖运行中的游戏客户端 | `source/Assembly-CSharp/Net/` |
| **扫码登录** | 微信二维码换 userId + token,走 aimedb | `source/ChimeLib.NET/` |
---
## 二、目录结构
```
wahappy-v0-goal/
├── config.py ★ 运行时可配置项(密钥/设备/区域/歌曲数据/日志级别)
├── upsert.py ★ 主脚本范例:扫码登录 → 上传成绩 → 登出
├── buy_ticket.py ★ 主脚本范例:扫码登录 → 购买功能票 → 登出
├── selftest.py 零网络自检(220 项,判据=源码+真机日志+真机抓包)
│
├── api/ ★ 所有与服务器交互的脚本(一个 api 一个脚本)
│ ├── __init__.py 统一导出 + 日志跟踪登记
│ ├── log_setup.py 统一日志(INFO / DEBUG 双模式)
│ ├── maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
│ ├── user_getter_common.py 取数脚本公共底座
│ ├── chime.py 扫码登录
│ ├── upsert_user_all.py ★ 上传成绩
│ └── ... 其余见第六节
│
├── source/ ★ 反编译源码 + 网络日志,只读查证用
├── docs/ ★ 文档
└── logs/ ★ 日志输出(latest.log + 日期.log)
```
---
## 三、api/ 脚本契约
`api/` 里的每个脚本是一个**功能模块**,对外暴露**函数**;函数**能接收参数**,
内部完成「组包 → 发包 → 解析 → 返回结果」的全过程。
```python
def 某Api函数(必填参数, *, 可选参数...) -> 结果:
"""一句话说明(会出现在日志的 [接口说明] 里)"""
...
```
参数形态规律:
- **业务数据**(user_id / token / 二维码 / playlog_id)→ **位置参数**
- **配置项**(client_id / region_id / timeout_ms)→ **关键字参数**,可省略走默认值
---
## 四、日志
### 4.1 两种模式
| 模式 | 输出内容 |
|---|---|
| **INFO**(默认) | 只展示 api 脚本的**运行情况和结果** |
| **DEBUG** | 在 INFO 基础上,额外展示**向 api 发送的数据**和**api 返回的数据** |
切换只改 `config.py` 一行:
```python
LOG_LEVEL = "info" # 只看流程
LOG_LEVEL = "debug" # 连收发数据一起看
```
### 4.2 运行提示
每个 api 函数工作时会提示开始,结束时提示结束:
```
=== ParseQRCode 开始运行 ===
[接口说明] 解析二维码并换取 userId 与 token。
[位置参数] 'SGWCMAID2610022219463A297AF4CA...'
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
=== ParseQRCode 运行结束 ===
```
DEBUG 模式额外输出:
```
-> 请求 GetGameSettingApi
URL : https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/83a5...
请求头 : {"Content-Type":"application/json","Mai-Encoding":"1.55"}
请求体 :
{
"placeId": 1000,
"clientId": "TESTCLIENT1"
}
<- 响应 GetGameSettingApi
状态码 : 200
耗时 : 312 ms
响应体 :
{
"isAouAccession": false,
"gameSetting": { ... }
}
```
### 4.3 日志文件(两种形式同时存在、同时写入)
| 文件 | 模式 | 说明 |
|---|---|---|
| `logs/latest.log` | **覆盖**(`w`) | 只包含**当次**主脚本运行产出的日志,下次运行被覆盖 |
| `logs/YYYY-MM-DD.log` | **追加**(`a`) | 存放**当日**产生的所有日志 |
每行都带 `[YYYY-MM-DD HH:MM:SS]` 时间戳。
### 4.4 主脚本怎么写
```python
from api import setup_logging, teardown_logging, ParseQRCode, UserLogin
def main():
setup_logging("我的主脚本") # 开头一句;api 调用从此自动记录
try:
user_id, token = ParseQRCode(qr)
login = UserLogin(user_id, token)
finally:
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
```
**不需要**写装饰器,**不需要**自己埋点 —— `api/__init__.py` 在导入时就统一套好了日志外壳。
---
## 五、协议实现要点(全部有源码依据)
| 环节 | 依据 | 要点 |
|---|---|---|
| **URL 生成** | `Packet.cs:485` + `NetQuery.cs:14` | BASE_URL + md5(接口名 + "MaimaiChn" + "8bF76dE9") |
| **请求封装** | `NetHttpClient.cs:262-300` | JSON → 原始 deflate → 0x78 0x9C + Adler32(大端) → AES-256-CBC |
| **响应解封装** | `NetHttpClient.cs:355-380` | AES 解密 → 去 2 字节头 / 4 字节尾 → deflate → JSON |
| **AES 参数** | `CipherAES.cs:11-12` | Key 32B / IV 16B / CBC / PKCS7 |
| **请求头** | `NetHttpClient.cs:174-190` | Mai-Encoding: 1.55、Content-Encoding: deflate、UA = 混淆名#userId |
| **number 头** | `Packet.cs:187-189` + `PingChecker.cs:227` | = Ping 心跳的往返毫秒(`GameManager.PingMsec`),**只由 Ping 刷新**;除 Ping 外每个接口都带 |
| **Cookie** | `Packet.cs:190-193,228-234` | ★ **UserLoginApi 响应会下发 `Set-Cookie: JSESSIONID=...`**,之后每个带 userId 的请求都会带上;`requests.Session` 自动处理,但**必须复用同一个 client**(`get_default_client()`) |
| **playSpecial** | `VOExtensions.cs:846-855` + `GameManager.cs:332-378` | `位反转32(Random(1,1037933)*2069 + CalcSpecialNum)`;标准 ACA 机台 `CalcSpecialNum = 1024 = 0x400` |
| **扫码签名** | 技术规范 §4 | SHA256(chipID + timestamp + KEY) 大写;**不带 Content-Type** |
| **loginDateTime** | `PacketUserLogin.cs:29` | 「本地墙上时间当 UTC」= 标准 Unix 秒 + 时区偏移 |
### ★ 一个必须注意的坑:Accept-Encoding 必须为空
`requests` 默认发 `Accept-Encoding: gzip, deflate`。本协议响应体是 AES 密文(二进制),
服务端若据此做内容编码会返回**空 body**。C# 端不需要是因为 `HttpWebRequest`
默认 `AutomaticDecompression = None`。所以 `maimai_net.py` 固定置空该头。
---
## 六、API 清单
### 6.1 会话链路(6 个)
| 脚本 | 函数 | 对应接口 |
|---|---|---|
| `chime.py` | `ParseQRCode(qr, *, chip_id)` | aimedb /wc_aime/api/get_data |
| `gamesettings.py` | `GetGameSettings(*, place_id, client_id)` | GetGameSettingApi |
| `ping.py` | `Ping(*, client_id)` | Ping |
| `userpreview.py` | `UserPreview(user_id, token, *, ...)` | GetUserPreviewApi |
| `userlogin.py` | `UserLogin(user_id, token, *, region_id, place_id)` | UserLoginApi |
| `userlogout.py` | `UserLogout(user_id, token, login_dt, *, logout_type)` | UserLogoutApi |
### 6.2 上传成绩(2 个)
| 脚本 | 函数 | 说明 |
|---|---|---|
| `upsert_user_all.py` | `UpsertUserAll(user_id, playlog_id, *, login_date_time, auth_date_time, token, snapshot, extra_data, play, ...)` | ★ **一个函数跑完整条结算链路**:抓快照 → 组包 → 前置接口 → 自检 → 60 秒冷却 → 发包 → 落库回查。唯一有副作用的接口 |
| `upsert_user_all.py` | `derive_play_characters(snapshot)` | 从服务器快照推「本局 5 只角色 + 游玩前等级」,免得手填的 `config.PLAY_CHARACTERS` 过期 |
| `upsert_user_all.py` | `load_extra_data(extra)` / `merge_extra_data(user_all, extra)` | 把外部 JSON(dict / JSON 串 / 文件路径)自动匹配插入 36 节点 |
| `upsert_user_all.py` | `verify_upsert_landing(user_id, snapshot, play)` | 发包后重新拉服务器数据,确认真的落库 |
| `upsert_snapshot.py` | `fetch_upsert_snapshot(user_id, ...)` | 一键抓结算基线快照(`UpsertUserAll` 内部会按需自动调) |
| `upsert_user_chargelog.py` | `UpsertUserChargelog(user_id, charge_id, login_date_time, ...)` | ★ **购买功能票**(UpsertUserChargelogApi):自动取当前票券/档案、算 validDate 与 price、闸门、发包 |
| `upsert_user_chargelog.py` | `calc_valid_date(now)` / `ticket_price(id)` / `ticket_name(id)` | 有效期算法(-4h+90d)、票的 creditNum、票名 |
### 6.3 取数(一个接口一个脚本)
| 脚本 | 函数 | 对应接口 |
|---|---|---|
| `userdata.py` | UserData | GetUserDataApi |
| `user_extend.py` | UserExtend | GetUserExtendApi |
| `user_option.py` | UserOption | GetUserOptionApi |
| `user_character.py` | UserCharacter | GetUserCharacterApi |
| `user_ghost.py` | UserGhost | GetUserGhostApi |
| `user_map.py` | UserMap / UserMapAll | GetUserMapApi |
| `user_login_bonus.py` | UserLoginBonus / UserLoginBonusAll | GetUserLoginBonusApi |
| `user_rating.py` | UserRating | GetUserRatingApi |
| `user_item.py` | UserItem / UserItemAll | GetUserItemApi |
| `user_music.py` | UserMusicDetail / UserMusicDetailAll | GetUserMusicApi |
| `user_course.py` | UserCourse / UserCourseAll | GetUserCourseApi |
| `user_charge.py` | UserCharge | GetUserChargeApi |
| `user_favorite.py` | UserFavorite / UserFavoriteAll | GetUserFavoriteApi |
| `user_activity.py` | UserActivity | GetUserActivityApi |
| `user_mission.py` | UserMissionData | GetUserMissionDataApi |
| `user_intimate.py` | UserIntimate | GetUserIntimateApi |
| `user_shop_stock.py` | UserShopStock | GetUserShopStockApi |
| `user_favorite_item.py` | UserFavoriteItem / UserFavoriteItemAll | GetUserFavoriteItemApi |
| `user_rival.py` | UserRivalData / UserRivalMusic(All) | GetUserRivalDataApi / GetUserRivalMusicApi |
| `user_friend_bonus.py` | UserFriendBonus | GetUserFriendBonusApi |
| `user_kaleidx_scope.py` | UserKaleidxScope / GameKaleidxScope | GetUserKaleidxScopeApi / GetGameKaleidxScopeApi |
| `user_region.py` | UserRegion | GetUserRegionApi |
| `user_recommend_music.py` | UserRecommendRateMusic / UserRecommendSelectMusic | GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi |
| `user_new_item.py` | UserNewItemList | GetUserNewItemListApi |
| `game_ranking.py` | GetGameRanking | GetGameRankingApi |
| `game_event.py` | GetGameEvent | GetGameEventApi |
| `game_charge.py` | GetGameCharge | GetGameChargeApi |
| `game_weekly_data.py` | GetGameWeeklyData | GetGameWeeklyDataApi |
| `game_tournament_info.py` | GetGameTournamentInfo | GetGameTournamentInfoApi |
| `game_ng_music_id.py` | GetGameNgMusicId | GetGameNgMusicIdApi |
---
## 七、新增一个 api 要做什么
1. 在 `api/` 下新建脚本,定义一个能传参的函数
2. 函数首行写 docstring(会变成日志的 `[接口说明]`)
3. 用 `from .log_setup import log_info, log_request, log_response` 记日志
4. 用 `from .maimai_net import request_dict` 发包
5. 在 `api/__init__.py` 里**导入**并**加进 `_API_FUNCS`** ← 漏了这步就不会有运行提示
---
## 八、相关文档
| 文件 | 内容 |
|---|---|
| `docs/上传成绩开发文档.md` | UpsertUserAll 的 36 节点 / 110 字段 / 差分语义 / 真机对照 |
| `docs/主脚本编写指南.md` | ★ **写自己的主脚本**:三条铁律 / 最小骨架 / 四个流程模板 / 常见错误 |
| `docs/购买功能票流程.md` | ★ 登录 → 买票 → 登出:接口结构 / 源码流程 / validDate 算法 / 待抓包确认项 |
| `docs/UpsertUserAll踩坑清单.md` | ★ **28 条坑的逐条记录**:现象 / 根因 / 源码依据 / 正确做法 / 自检项 + 发包前 Checklist |
| `docs/实跑验证记录.md` | ★ **真实跑通记录**:returnCode=1 + 回读确认 playCount 1260→1261,含完整时间线与落库证据 |
| `docs/真机Hook对照报告.md` | ★ MelonLoader Mod 真机抓包(70 请求)与本实现的逐项对照:number / Cookie / CalcSpecialNum / 剩余差异说明 |
| `docs/UpsertUserAll实跑对比.md` | 逐字段对比明细(110 字段 × 真机 vs 本工程) |
| `docs/UpsertUserAll落库排查报告.md` | 「returnCode=1 但没落库」的根因分析 |
| `docs/日志与主脚本指南.md` | 日志模块完整参考 + 主脚本写法 |
| `docs/舞萌DX_扫码登录协议技术规范.md` | 扫码登录协议(上一代整理) |
| `source/2026-10-05.log` | 真机网络日志(本工程最重要的判据) |
---
*新增 api 后请同步更新第六节清单。*