docs: 新增《购买功能票_存档与交接》
买票功能暂停,归档全部有用信息以便将来恢复: - 协议事实(源码依据 + 真机 328 字节抓包原文) - 完整实验时间线(8 次失败)与 12 项已排除假设 - ★ 同一账号对照实验证明差异在客户端(机台能买、脚本不能买) - 别人的可用实现(截图转录)+ 12 项差异对照 + 6 个疑惑 - 账号风险提示、恢复工作的步骤与命令
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
# 购买功能票 —— 存档与交接文档
|
||||
|
||||
> **状态:暂停(2026-10-06)**
|
||||
> 暂停原因:多次失败购买后主账号疑似被服务端拉黑;实现仍未跑通。
|
||||
> 本文把「从开始做买票 → 到暂停」之间的**全部有用信息**归档,
|
||||
> 目的是**将来恢复这项工作时能 5 分钟上手**,不用重新踩一遍。
|
||||
|
||||
---
|
||||
|
||||
## 〇、30 秒速览
|
||||
|
||||
1. **协议已完全搞清楚** —— 源码逐行 + 真机抓包 328 字节原文都有
|
||||
2. **报文已做到与真机逐字节一致**,但服务端仍回 `returnCode=0`
|
||||
3. **同一账号对照实验证明:机台能买、脚本不能买** → 差异在**客户端**,不是账号/服务端
|
||||
4. 差异最可能在 **HTTP 层**(请求头 / Cookie / 连接特征),**不在报文体**
|
||||
5. 暂停前最后动作:给 MelonLoader Mod 加了「完整请求头 + 响应头 dump」,**等一次真机抓包**
|
||||
6. ⚠️ **恢复工作前先确认账号没被拉黑**(见第八节)
|
||||
|
||||
---
|
||||
|
||||
## 一、结论速览
|
||||
|
||||
| 项 | 状态 |
|
||||
|---|---|
|
||||
| 接口名 / URL 混淆 | ✅ 已确认 |
|
||||
| 请求体结构(4 顶层 + 8 + 4 字段) | ✅ 与真机逐字节一致 |
|
||||
| `purchaseDate` 的 `.f` 小数位 | ✅ 已确认并修正 |
|
||||
| `validDate` 算法 | ✅ 已确认并双向验证 |
|
||||
| `price` 语义(= `ticket.creditNum`) | ✅ 已确认 |
|
||||
| `stock` 语义(购买后的值) | ✅ 已确认 |
|
||||
| 键集合 / 字段顺序 | ✅ 与真机一致 |
|
||||
| 可购买清单校验 | ✅ 已实现(真机也有这一步) |
|
||||
| 已持票守卫 | ✅ 已实现(**自加的安全措施**,真机没有) |
|
||||
| **服务端接受** | ❌ **`returnCode=0`** |
|
||||
| **根因** | ❓ **未定位**,怀疑 HTTP 层 |
|
||||
|
||||
---
|
||||
|
||||
## 二、协议事实(全部有源码依据)
|
||||
|
||||
### 2.1 接口
|
||||
|
||||
| 项目 | 值 | 依据 |
|
||||
|---|---|---|
|
||||
| 逻辑 API 名 | `UpsertUserChargelogApi` | `Net.Packet.Mai2/PacketUploadUserChargelog.cs:19` |
|
||||
| URL 尾段 | `md5("UpsertUserChargelogApiMaimaiChn" + "8bF76dE9")` = `4411a4c8f7f5c0e95a3ac17f1746aad3` | `Packet.cs:485` + `NetQuery.cs:14` |
|
||||
| 请求 VO | `Net.VO.Mai2/UserChargelogRequestVO.cs` | — |
|
||||
| 响应 | `{returnCode, apiName}` | — |
|
||||
| 副作用 | ★ 有,写入购买记录 | — |
|
||||
|
||||
### 2.2 请求体(字段顺序照 C# 声明)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"userId": <ulong>,
|
||||
"userChargelog": { // UserChargelog.cs,8 字段
|
||||
"chargeId": 2,
|
||||
"price": 1, // ★ = ticket.creditNum,不是投币数
|
||||
"purchaseDate": "2026-10-06 13:20:10.0", // ★ 带 .f 一位小数
|
||||
"playCount": 1262,
|
||||
"playerRating": 15000,
|
||||
"placeId": 1000,
|
||||
"regionId": 5,
|
||||
"clientId": "TESTCLIENT1"
|
||||
},
|
||||
"userCharge": { // UserCharge.cs,4 字段
|
||||
"chargeId": 2,
|
||||
"stock": 1, // ★ 购买后的库存(= 原值 + 1)
|
||||
"purchaseDate": "2026-10-06 13:20:10.0",
|
||||
"validDate": "2027-01-04 04:00:00" // ★ 不带小数
|
||||
},
|
||||
"loginDateTime": 1791159576 // ★ long,来自 UserLoginApi
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 两个容易踩的格式
|
||||
|
||||
| 字段 | 格式 | 例子 |
|
||||
|---|---|---|
|
||||
| `purchaseDate` | `TimeManager.DateTimeFormat` = `yyyy-MM-dd HH:mm:ss.f` | `2026-10-06 13:20:10.0` |
|
||||
| `validDate` | 显式串 `yyyy-MM-dd 04:00:00` | `2027-01-04 04:00:00` |
|
||||
|
||||
> **一个带小数一个不带** —— 少写 `.0` 服务端直接 `returnCode=0`(踩过)。
|
||||
|
||||
### 2.4 `validDate` 算法
|
||||
|
||||
`Manager/TicketManager.cs:562-565`:
|
||||
|
||||
```csharp
|
||||
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||||
```
|
||||
|
||||
真机双向验证:
|
||||
|
||||
| chargeId | 真机 purchaseDate | 减 4h + 90d | 真机 validDate | 吻合 |
|
||||
|---|---|---|---|---|
|
||||
| 5 | 2026-10-04 18:22:10 | 2027-01-02 | `2027-01-02 04:00:00` | ✅ |
|
||||
| 3 | 2026-10-02 10:49:23 | 2026-12-31 | `2026-12-31 04:00:00` | ✅ |
|
||||
|
||||
### 2.5 `price` = `ticket.creditNum`
|
||||
|
||||
`Net.VO.Mai2/VOExtensions.cs:410`:`result.price = ticket.creditNum;`
|
||||
|
||||
数据来自游戏本地主数据 `Sinmai_Data/StreamingAssets/A000/ticket/ticketXXXXXX/Ticket.xml`
|
||||
(已抄进 `config.TICKET_CREDIT_NUM`):
|
||||
|
||||
| chargeId | 票名 | `creditNum`(上报的 price) | 机台投币数 |
|
||||
|---|---|---|---|
|
||||
| 2 | 2倍チケット | **1** | 1 |
|
||||
| 3 | 3倍チケット | **2** | 2 |
|
||||
| 4 | 4倍チケット | 3 | — |
|
||||
| 5 | 5倍チケット | 4 | — |
|
||||
| 6 | 6倍チケット | 5 | — |
|
||||
|
||||
### 2.6 真机的完整流程
|
||||
|
||||
```
|
||||
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
|
||||
├─ ticketKind.id == 1(付费票)
|
||||
├─ :137-150 在 GetGameChargeDataList() 里找该 chargeId
|
||||
│ ★ 找不到直接 return false,不发请求
|
||||
├─ :207 SetBuyPaidTicketData -> stock++、更新 purchaseDate / validDate
|
||||
└─ :234 RequestUserCharge
|
||||
├─ VOExtensions.cs:436 ExportEnableUserCharge -> 购买后的 UserCharge
|
||||
├─ VOExtensions.cs:403 ExportUserChargelog -> price = ticket.creditNum
|
||||
└─ 发 UpsertUserChargelogApi
|
||||
```
|
||||
|
||||
成功后 `TicketManager.cs:255 RequestSuccess` 会**本地** `stock--`(票被本局用掉),
|
||||
但这个减一**不会发给服务器** —— 所以服务端会一直保留 `stock=1`,
|
||||
直到下一次结算的 `UpsertUserAll` 把新的 `userChargeList` 写回去。
|
||||
|
||||
---
|
||||
|
||||
## 三、真机抓包原文(★ 成功样本)
|
||||
|
||||
来源:`MaimaiNetSpy/raw/061_UpsertUserChargelogApiMaimaiChn_request.json`(328 字节)
|
||||
|
||||
```json
|
||||
{"userId":12345678,"userChargelog":{"chargeId":2,"price":1,"purchaseDate":"2026-10-06 13:20:10.0","playCount":1262,"playerRating":15000,"placeId":1000,"regionId":5,"clientId":"TESTCLIENT1"},"userCharge":{"chargeId":2,"stock":1,"purchaseDate":"2026-10-06 13:20:10.0","validDate":"2027-01-04 04:00:00"},"loginDateTime":1791159576}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{"returnCode":1,"apiName":"com.sega.maimai.api.UpsertUserChargelogApi"}
|
||||
```
|
||||
|
||||
请求前后的时序(同一会话):
|
||||
|
||||
```
|
||||
[13] 13:19:45 UserLoginApi
|
||||
[14]~[60] 入场全量数据下载(47 个请求)
|
||||
[61] 13:20:10 UpsertUserChargelogApi ← 成功
|
||||
[62] 13:20:46 GetUserNewItemListApi
|
||||
[63] 13:20:53 UserLogoutApi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、我的实现现状
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| `api/upsert_user_chargelog.py` | 买票 API。报文构造 + 三道守卫 + 闸门 |
|
||||
| `buy_ticket.py` | 主脚本:扫码 → 登录 → 入场下载 → 查票 → 等待 → 买票 → 查票 → 登出 |
|
||||
|
||||
### 4.1 `UpsertUserChargelog` 参数
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `user_id` / `charge_id` / `login_date_time` | 必填 | 后两个必须与 `UserLoginApi` 一致 |
|
||||
| `charge_list` | 自动抓 | 当前票券(`GetUserChargeApi`) |
|
||||
| `game_charge_list` | 自动抓 | 可购买清单(`GetGameChargeApi`) |
|
||||
| `user_data` | 自动抓 | 取 `playCount` / `playerRating` |
|
||||
| `stock` | 现有 + 1 | 购买后库存 |
|
||||
| `price` | `TICKET_CREDIT_NUM[charge_id]` | = `creditNum` |
|
||||
| `purchase_date` / `valid_date` | 现在 / 由前者推 | |
|
||||
| `guard_purchasable` | `True` | 不在可购买清单里就拒绝 |
|
||||
| `guard_no_stock` | `True` | ★ **已持票就拒绝**(自加,真机没有) |
|
||||
| `confirm` | `config.UPSERT_CHARGELOG_CONFIRM` | 不开不发包 |
|
||||
|
||||
### 4.2 已验证「正确」的东西
|
||||
|
||||
| 项 | 证据 |
|
||||
|---|---|
|
||||
| URL / 混淆名 | `4411a4c8f7f5c0e95a3ac17f1746aad3` = 真机 |
|
||||
| User-Agent | 逐字符相同 |
|
||||
| 键集合 / 顺序 / 类型 | 与真机逐字节同构(328 B vs 328 B) |
|
||||
| `.f` 格式 | `purchaseDate` 已带一位小数 |
|
||||
| Cookie | 登录响应确实下发 `Set-Cookie: JSESSIONID=...`,Session 自动带 |
|
||||
| `number` 头 | 已调 `Ping()`(真机 75,我这边 47~61) |
|
||||
| 入场数据下载 | 已补(18~19 节点) |
|
||||
| 节奏 | 已加 30 秒等待(真机是 25 秒) |
|
||||
|
||||
### 4.3 我发出的实际请求头(离线检视)
|
||||
|
||||
```
|
||||
User-Agent = '4411a4c8f7f5c0e95a3ac17f1746aad3#<userId>'
|
||||
Accept-Encoding = '' ← 空串(.NET 不发这个头)
|
||||
Accept = '*/*' ← requests 自带
|
||||
Connection = 'keep-alive'
|
||||
Content-Type = 'application/json'
|
||||
charset = 'UTF-8'
|
||||
Mai-Encoding = '1.55'
|
||||
Content-Encoding = 'deflate'
|
||||
number = '61'
|
||||
Cookie = 'JSESSIONID=...'
|
||||
Content-Length = '32'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、完整实验时间线(全部失败)
|
||||
|
||||
| # | 时间 | 账号 | chargeId | 关键改动 | 结果 |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 13:10 | A | 2 | `purchaseDate` **少 `.0`** | `returnCode=0` |
|
||||
| 2 | 13:41 | A | 5 | 格式已修 | `returnCode=0`(★ 5 号票**未上架**) |
|
||||
| 3 | 13:50 | A | 3 | 换了上架的票 | `returnCode=0` |
|
||||
| 4 | 13:53 | A | 3 | + 入场全量下载 | `returnCode=0` |
|
||||
| 5 | 14:03 | A | 2 | + 5 秒等待 | `returnCode=0` |
|
||||
| 6 | 15:51 | A | 3 | + `Ping()`、30 秒等待 | `returnCode=0` |
|
||||
| 7 | 16:33 | **B** | 2 | 换了个**没被黑的号** | 🛡️ 被守卫拦下(B 已持票) |
|
||||
| 8 | **16:39** | **B** | 2 | B 的票已用掉,守卫放行 | ❌ **`returnCode=0`** |
|
||||
|
||||
### ★ 第 8 次是决定性实验
|
||||
|
||||
| | 机台 | 我的脚本 |
|
||||
|---|---|---|
|
||||
| 账号 A | ✅ 13:20、14:06 成功 | ❌ 6 次失败 |
|
||||
| **账号 B** | ✅ **16:31:51 成功** | ❌ **16:39:37 失败** |
|
||||
|
||||
**同一个账号 B:机台能买、脚本不能买 → 差异在客户端,不在账号/服务端。**
|
||||
|
||||
> 此前一直以为是「账号被拉黑」,第 8 次推翻了它。
|
||||
|
||||
---
|
||||
|
||||
## 六、已排除的假设
|
||||
|
||||
| # | 假设 | 排除依据 |
|
||||
|---|---|---|
|
||||
| 1 | URL / 混淆名错 | 服务端回正确 `apiName` |
|
||||
| 2 | User-Agent 错 | 与真机逐字符相同 |
|
||||
| 3 | Cookie 缺失 | 登录响应确有 `Set-Cookie: JSESSIONID` |
|
||||
| 4 | 报文字段 / 顺序 / 类型错 | 与真机逐字节同构 |
|
||||
| 5 | `purchaseDate` 格式错 | 已补 `.f`(第 1 次之后) |
|
||||
| 6 | 票未上架 | 第 3 次起换成清单里的票 |
|
||||
| 7 | 已持有功能票 | 第 8 次确认持有 0 张 |
|
||||
| 8 | `number` 头为 0 | 第 6 次起已 `Ping()` |
|
||||
| 9 | 缺少入场数据下载 | 第 4 次起已补 |
|
||||
| 10 | 节奏太快 | 第 6 次起 30 秒等待 |
|
||||
| 11 | `chargeId` 本身有问题 | 换用真机验证过的 2 号票仍失败 |
|
||||
| 12 | 账号被拉黑 | ★ **第 8 次用没被黑的号 B 仍然失败** |
|
||||
|
||||
### 剩余假设
|
||||
|
||||
| # | 假设 | 怎么验证 |
|
||||
|---|---|---|
|
||||
| **A** | ★ **HTTP 请求头 / 连接特征有差异** | 抓真机完整请求头逐字节对比(mod 已就绪) |
|
||||
| B | 服务端对金钱接口做了 TLS 指纹校验 | 需要 MITM 代理 |
|
||||
| C | 需要某个本工程不知道的前置状态 | 同上 |
|
||||
|
||||
---
|
||||
|
||||
## 七、★ 别人的可用实现(截图转录)
|
||||
|
||||
> 图片主人说这套代码**能成功买票**,流程为「登录 → userdata → ticket → logout」。
|
||||
|
||||
### 7.1 报文构造
|
||||
|
||||
```python
|
||||
import json
|
||||
import pytz
|
||||
from .sdgb import sdgb_api
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
def get_ticket(userId: int, cookie, chargeId, price, regionId, placeId,
|
||||
clientId, playCount: int, playerRating: int):
|
||||
now = (datetime.now(pytz.timezone('Asia/Shanghai'))).strftime("%Y-%m-%d %H:%M:%S.0")
|
||||
validDate = (datetime.now(pytz.timezone('Asia/Shanghai')) + timedelta(days=90)) \
|
||||
.replace(hour=4, minute=0, second=0).strftime("%Y-%m-%d %H:%M:%S")
|
||||
data = json.dumps({
|
||||
"userId": userId,
|
||||
"userCharge": {
|
||||
"chargeId": chargeId,
|
||||
"stock": 1,
|
||||
"purchaseDate": now,
|
||||
"validDate": validDate
|
||||
},
|
||||
"userChargelog": {
|
||||
"chargeId": chargeId,
|
||||
"price": price,
|
||||
"purchaseDate": now,
|
||||
"placeId": placeId,
|
||||
"regionId": regionId,
|
||||
"clientId": clientId,
|
||||
"playCount": playCount,
|
||||
"playerRating": playerRating,
|
||||
},
|
||||
"loginDate": now, # ★★ 注意:是 loginDate(字符串),不是 loginDateTime
|
||||
})
|
||||
|
||||
ticket_result = sdgb_api(data, "UpsertUserChargelogApi", cookie)
|
||||
return ticket_result
|
||||
```
|
||||
|
||||
### 7.2 调用方流程
|
||||
|
||||
```python
|
||||
if ticket_id in ticket_list:
|
||||
login_time = login_datetime_now()
|
||||
login_result = user_login_checked(user_id, token, region_id, place_id, client_id, login_time)
|
||||
if login_result['Result']['returnCode'] == 1:
|
||||
login_cookie = login_result['Cookie'] # ★ 显式取出 cookie
|
||||
logger.info(f'{user_id} get_ticket: 获取到 Cookie = {login_cookie}')
|
||||
|
||||
match ticket_id: # ★ price 表
|
||||
case 2: ticket_price = 1
|
||||
case 3: ticket_price = 2
|
||||
case 4: ticket_price = 3
|
||||
case 5: ticket_price = 4
|
||||
case 6: ticket_price = 5
|
||||
case _: ticket_price = 0
|
||||
|
||||
userdata_result = user_datas(user_id, login_cookie) # ★ cookie 显式传
|
||||
play_count = userdata_result["userData"]["playCount"]
|
||||
player_rating = userdata_result["userData"]["playerRating"]
|
||||
|
||||
# ★★ 获取 userdata 后等待 60 秒再请求 ticket
|
||||
logger.info(f'{user_id} get_ticket: 等待 60 秒后请求 ticket')
|
||||
time.sleep(60)
|
||||
|
||||
ticket_result = api_get_ticket(
|
||||
user_id, login_cookie, ticket_id, ticket_price,
|
||||
region_id, place_id, client_id, play_count, player_rating
|
||||
)
|
||||
logger.debug(f'ticket_result 完整内容 = {json.dumps(ticket_result, ensure_ascii=False, indent=2)}')
|
||||
|
||||
if ticket_result and ticket_result.get('returnCode') == 1:
|
||||
logout_result = user_logout(user_id, login_cookie, region_id, place_id, client_id, 0, 4)
|
||||
...
|
||||
```
|
||||
|
||||
### 7.3 ★ 与我实现的差异对照
|
||||
|
||||
| # | 他们 | 我 | 重要性 |
|
||||
|---|---|---|---|
|
||||
| **1** | **`"loginDate": "2026-10-06 16:39:37.0"`**(字符串) | `"loginDateTime": 1791304745`(数字) | ★★★ **最大疑点** |
|
||||
| 2 | `stock` 硬编码 `1` | 现有 + 1 | 低(结果一样) |
|
||||
| 3 | 等待 **60 秒** | 30 秒 | 中 |
|
||||
| 4 | `validDate` = `(now+90d).replace(hour=4)` | `(now-4h+90d).date() + " 04:00:00"` | 低(源码支持我的) |
|
||||
| 5 | `purchaseDate` 的 `.0` 写死 | 取真实十分位 | 低 |
|
||||
| 6 | **Cookie 显式传递** | `requests.Session` 自动 | ★★ 中 |
|
||||
| 7 | 无 `GetGameChargeApi` 前置 | 有 | 低 |
|
||||
| 8 | 无「已持票」守卫 | 有(自加) | 低(安全相关) |
|
||||
| 9 | 流程:登录→userdata→ticket→logout | 登录→入场下载→查票→等待→ticket→查票→logout | ★ 中 |
|
||||
| 10 | `userCharge` 在 `userChargelog` 之前 | 反之 | 低(JSON 无序) |
|
||||
| 11 | `userChargelog` 内部顺序也不同 | 照 C# 声明 | 低 |
|
||||
| 12 | `sdgb_api(data, "UpsertUserChargelogApi", cookie)` | `request_dict(...)` | ★★ **黑盒,未知** |
|
||||
|
||||
### 7.4 ★ 我的疑惑(恢复工作时优先解答)
|
||||
|
||||
1. **`loginDate` vs `loginDateTime`(最重要)**
|
||||
- 源码 `UserChargelogRequestVO.cs` 声明的是 `public long loginDateTime;`
|
||||
- 真机抓包发的是 `"loginDateTime":1791159576`(数字)
|
||||
- **他们发的是 `"loginDate"`(字符串)**,而且说能成功
|
||||
- 三种可能:
|
||||
a. 服务端**不校验**这个字段 → 那我的 `loginDateTime` 也不是问题
|
||||
b. 服务端认的是 `loginDate`(旧字段名)
|
||||
c. 他们的 `sdgb_api` 内部做了改名(把 `loginDate` → `loginDateTime`)
|
||||
- **必须先搞清楚这一条**,否则会一直在错的方向排查
|
||||
|
||||
2. **`sdgb_api(data, apiName, cookie)` 到底做了什么?**(最大的黑盒)
|
||||
- 是否补 `MaimaiChn` 后缀?是否做 md5 混淆?
|
||||
- 请求头怎么设的?(`Accept-Encoding`?`number`?`Mai-Encoding`?`Content-Type`?)
|
||||
- **cookie 是怎么放进请求的**(header 直写?cookie jar?)
|
||||
- 加解密实现是否一致
|
||||
- ★ **他们的成功很可能就藏在这里**
|
||||
|
||||
3. **Cookie 显式传递的含义**
|
||||
- 他们从登录结果里取出 cookie,手工传给每个请求
|
||||
- 这个 cookie 是 `Set-Cookie` 原样,还是提取出的 `JSESSIONID=...`?
|
||||
- 和 `requests.Session` 自动管理的**有没有不同**?
|
||||
(比如:他们可能把**整个 Set-Cookie 串**塞进 `Cookie:` 头)
|
||||
|
||||
4. **60 秒等待是必需的吗?**
|
||||
- 真机是 25 秒,他们用 60 秒 —— 是协议要求还是保险起见?
|
||||
|
||||
5. **`stock` 硬编码 1**
|
||||
- 如果账号已有该票(`stock=1`),他们还是发 1
|
||||
- 那「已持票不能买」的规则到底存不存在?(我加守卫是基于用户的警告)
|
||||
|
||||
6. **`validDate` 在凌晨会差一天**
|
||||
- 他们的算法在 00:00~04:00 之间会比我多算一天
|
||||
- 谁对?(源码支持我的,但他们的能跑通)
|
||||
|
||||
---
|
||||
|
||||
## 八、⚠️ 账号状态与风险
|
||||
|
||||
| 账号 | 状态 | 备注 |
|
||||
|---|---|---|
|
||||
| **A**(主号) | ⚠️ **疑似被拉黑** | 6 次失败购买后,机台与脚本都买不了 |
|
||||
| **B**(测试号) | 暂未见异常 | 机台成功过;脚本失败 1 次 |
|
||||
|
||||
**风险提示:**
|
||||
|
||||
1. **不要反复重试买票** —— 每次都是一次真实的服务端交互,**可能正是失败次数导致拉黑**
|
||||
2. 恢复工作前**先用机台确认账号还能不能买**
|
||||
3. 优先用**非主力账号**做实验
|
||||
4. 已有的三道守卫(可购买清单 / 已持票 / 副作用闸门)**不要为了「试通」而关掉**
|
||||
|
||||
---
|
||||
|
||||
## 九、恢复工作时怎么做
|
||||
|
||||
### 9.1 先读这些
|
||||
|
||||
| 文件 | 看什么 |
|
||||
|---|---|
|
||||
| 本文 | 全部背景 |
|
||||
| `api/upsert_user_chargelog.py` | 实现与守卫 |
|
||||
| `buy_ticket.py` | 主脚本流程 |
|
||||
| `source/Assembly-CSharp/Net.Packet.Mai2/PacketUploadUserChargelog.cs` | 接口定义 |
|
||||
| `source/Assembly-CSharp/Manager/TicketManager.cs:130-270` | 真机购买流程 |
|
||||
| `source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:403-451` | 三个 Export 函数 |
|
||||
|
||||
### 9.2 第一件事:抓真机完整 HTTP 头
|
||||
|
||||
**MelonLoader Mod 已经改好了**(`gamehook/Main.cs`),现在会 dump:
|
||||
|
||||
- `★ 真实 HTTP 请求头(HttpWebRequest,含框架自带)` —— Method / ContentType / UserAgent /
|
||||
ContentLength / KeepAlive / Expect / AutomaticDecompression / **Headers.AllKeys** / **Cookie 头**
|
||||
- `★ 真实 HTTP 响应头` —— StatusCode / 全部响应头(含 **`Set-Cookie: JSESSIONID`**)
|
||||
|
||||
构建与部署:
|
||||
|
||||
```bash
|
||||
cd gamehook && dotnet build -c Release
|
||||
copy bin\Release\MaimaiNetSpy.dll "D:\SDGB2026\Package\Mods\MaimaiNetSpy.dll"
|
||||
```
|
||||
|
||||
然后**在机台上用没被黑的账号买一张票**,把日志发回来:
|
||||
|
||||
```
|
||||
D:\SDGB2026\Package\MaimaiNetSpy\YYYY-MM-DD_HHMMSS.log
|
||||
```
|
||||
|
||||
### 9.3 拿到日志后怎么比
|
||||
|
||||
1. 找 `UpsertUserChargelogApi` 那一段
|
||||
2. 对比**请求头集合**:多了什么?少了什么?
|
||||
- 重点:`Expect`、`Accept-Encoding`、`Cookie`、`Connection`、以及**头的顺序**
|
||||
3. 对比 `Cookie` 头:真机发的是 `JSESSIONID=...` 还是别的?
|
||||
4. 对比登录响应的 `Set-Cookie`:和我 Session 里存的是不是同一个
|
||||
5. 如果请求头完全一致 → 转向 TLS 指纹 / MITM 抓完整 TCP 层
|
||||
|
||||
### 9.4 验证买票是否成功的命令
|
||||
|
||||
```bash
|
||||
# 预检(不发包)
|
||||
python buy_ticket.py "<二维码>" --charge-id 2
|
||||
|
||||
# 真实购买(★ 有副作用)
|
||||
python buy_ticket.py "<二维码>" --charge-id 2 --send
|
||||
|
||||
# 可选参数
|
||||
--wait-before 60 # 默认 30 秒
|
||||
--skip-entry # 跳过入场全量下载
|
||||
--debug # 含完整收发报文
|
||||
```
|
||||
|
||||
### 9.5 一个可能的快速验证
|
||||
|
||||
如果第 7.4 节的疑惑 1(`loginDate` vs `loginDateTime`)成立,
|
||||
那么最快的验证是:**在 `extra_data` 里把 `loginDateTime` 换掉**——
|
||||
|
||||
但目前 `extra_data` 只作用于 `upsertUserAll` 的 36 个节点,
|
||||
**顶层字段(`userId` / `userChargelog` / `userCharge` / `loginDateTime`)改不了**,
|
||||
需要临时改 `upsert_user_chargelog.py` 的 `UserChargelogRequestVO` 定义。
|
||||
|
||||
---
|
||||
|
||||
## 十、代码位置索引
|
||||
|
||||
| 位置 | 内容 |
|
||||
|---|---|
|
||||
| `api/upsert_user_chargelog.py` | `UpsertUserChargelog` / `ChargelogConfirmError` / `ChargelogGuardError` / `calc_valid_date` / `parse_wall_time` / `now_cst` |
|
||||
| `buy_ticket.py` | 主脚本 + `show_charges()`(跑前跑后查票) |
|
||||
| `config.py` | `TICKET_CREDIT_NUM` / `TICKET_NAMES` / `PAID_TICKET_IDS` / `UPSERT_CHARGELOG_CONFIRM` / `TICKET_VALID_DAYS` / `TICKET_VALID_HOUR_OFFSET` |
|
||||
| `selftest.py` | 13.6 节:买票相关的 20+ 项自检(含**与真机报文逐字节回归**) |
|
||||
| `gamehook/Main.cs` | `PostRequestDumpHeaders` / `PostGetResponse`(响应头) |
|
||||
|
||||
---
|
||||
|
||||
*本文写于 2026-10-06,买票功能暂停时。*
|
||||
Reference in New Issue
Block a user