Files
Astral/docs/购买功能票_存档与交接.md
T
Txiaochen ddacc97b18 docs: 新增《购买功能票_存档与交接》
买票功能暂停,归档全部有用信息以便将来恢复:
- 协议事实(源码依据 + 真机 328 字节抓包原文)
- 完整实验时间线(8 次失败)与 12 项已排除假设
- ★ 同一账号对照实验证明差异在客户端(机台能买、脚本不能买)
- 别人的可用实现(截图转录)+ 12 项差异对照 + 6 个疑惑
- 账号风险提示、恢复工作的步骤与命令
2026-10-06 16:49:41 +08:00

499 lines
20 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.
# 购买功能票 —— 存档与交接文档
> **状态:暂停(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,买票功能暂停时。*