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

478 lines
18 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.
# 购买功能票流程(登录 → 买票 → 登出)
> 对应 `api/upsert_user_chargelog.py` 与主脚本范例 `buy_ticket.py`。
> **判据 = `source/Assembly-CSharp/` 反编译源码 + 游戏本地主数据 Ticket.xml。**
>
> ⚠ 本文的实现**全部来自源码推导**,还**没有真机 NetworkLog 对照**。
> 文末列出「需要一次抓包确认」的点。
---
## 一、一句话
在机台买一张功能票 = 往服务器发一个 `UpsertUserChargelogApi`,
把「买了哪张票、多少钱、什么时候买的、有效期到什么时候、买完还剩几张」写进去。
```
ParseQRCode -> UserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
```
---
## 二、接口
| 项目 | 值 |
|---|---|
| 逻辑 API 名 | `UpsertUserChargelogApi` |
| 源码 | `Net.Packet.Mai2/PacketUploadUserChargelog.cs:19` |
| 请求 VO | `Net.VO.Mai2/UserChargelogRequestVO.cs`(4 字段) |
| 响应 | `{returnCode, apiName}` |
| 副作用 | ★ 有,会写入一条购买记录 |
### 2.1 请求体(字段顺序照 C# 声明)
```jsonc
{
"userId": 12345678,
"userChargelog": { // ← UserChargelog.cs,8 字段
"chargeId": 2, // 买的哪张票
"price": 1, // ★ = ticket.creditNum,不是 money price
"purchaseDate": "2026-10-06 13:01:22",
"playCount": 1260, // 玩家总游玩次数
"playerRating": 15000, // 玩家 Rating
"placeId": 1000,
"regionId": 1,
"clientId": "TESTCLIENT1"
},
"userCharge": { // ← UserCharge.cs,4 字段
"chargeId": 2,
"stock": 1, // ★ 购买后的库存
"purchaseDate": "2026-10-06 13:01:22",
"validDate": "2027-01-04 04:00:00"
},
"loginDateTime": 1791291143
}
```
---
## 三、源码流程(逐行可查)
```
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
│
├─ ticket.ticketKind.id == 1 ? ← 只有 Paid 票才走购买
│ (ticketKind: 1=Paid 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara)
│
├─ 在 OperationManager.GetGameChargeDataList() 里找该 chargeId 的 price
│ (= GetGameChargeApi 的响应,含 orderId/chargeId/price/startDate/endDate)
│
├─ TicketManager.cs:207 SetBuyPaidTicketData(playerId, ticketId)
│ ├─ ChargeList 里已有该 chargeId -> stock++、更新 purchaseDate / validDate
│ └─ 没有 -> 新增一条 stock=1
│
└─ TicketManager.cs:234 RequestUserCharge(playerId)
├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId)
│ -> 取**购买后**(stock > 0)的那条 UserCharge
│
├─ VOExtensions.cs:403 ExportUserChargelog(ticketId)
│ chargeId = ticketId
│ price = DataManager.GetTicket(ticketId).creditNum ★
│ purchaseDate = TimeManager.GetNowDateString()
│ playCount = src.Detail.PlayCount
│ playerRating = (int)src.Detail.Rating
│ placeId = Auth.LocationId
│ regionId = Auth.RegionCode
│ clientId = AMDaemon.System.KeychipId.ShortValue
│
└─ 发 UpsertUserChargelogApi
returnCode == 1 -> RequestSuccess(本地 stock 再 --,因为票马上被用掉)
否则 -> RequestFail
```
### 3.1 ★ `price` 不是「花的钱」
`VOExtensions.cs:410`:
```csharp
result.price = ticket.creditNum;
```
`creditNum` 是**票的信用点倍数**,来自游戏本地主数据,**不是** `GetGameChargeApi` 的 money price。
| chargeId | 票名 | `creditNum`(= 上报的 price) | `GetGameChargeApi.price`(投币数) |
|---|---|---|---|
| 2 | 2倍チケット | **1** | 1 |
| 3 | 3倍チケット | **2** | 2 |
| 4 | 4倍チケット | **3** | 未上架 |
| 5 | 5倍チケット | **4** | 未上架 |
| 6 | 6倍チケット | **5** | 未上架 |
> 2/3 号票两者数值恰好相同,别被误导 —— 源码取的是 `creditNum`。
> 数据已抄进 `config.TICKET_CREDIT_NUM`,来源:
> `<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml`
### 3.2 ★ `validDate` 算法
`Manager/TicketManager.cs:564`:
```csharp
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
```
**真机双向验证**(`source/2026-10-05.log:123` 的 `userChargeList`):
| chargeId | purchaseDate | 减 4h | 加 90d | validDate(真机) | 吻合 |
|---|---|---|---|---|---|
| 5 | 2026-10-04 18:22:10 | 10-04 14:22 | 2027-01-02 | `2027-01-02 04:00:00` | ✅ |
| 3 | 2026-10-02 10:49:23 | 10-02 06:49 | 2026-12-31 | `2026-12-31 04:00:00` | ✅ |
---
## 四、怎么用
### 4.1 主脚本(`buy_ticket.py`)
```bash
# 预检:只看报文,零副作用
python buy_ticket.py "<二维码>" --charge-id 2
# 先看看现在有什么票、能买什么
python buy_ticket.py "<二维码>" --list
# ★ 真实购买
python buy_ticket.py "<二维码>" --charge-id 2 --send
```
### 4.2 在代码里调
```python
from api import (setup_logging, teardown_logging,
ParseQRCode, UserLogin, UserLogout, LogoutType,
UpsertUserChargelog)
setup_logging("买票")
try:
user_id, token = ParseQRCode(qr)
login = UserLogin(user_id, token)
# ★ 一个函数搞定:取当前票券 -> 取档案 -> 算 validDate -> 算 price -> 闸门 -> 发包
UpsertUserChargelog(user_id, 2, login.loginDateTime,
dry_run=False, confirm=True)
UserLogout(user_id, token, login.loginDateTime,
logout_type=LogoutType.TestIn)
finally:
teardown_logging()
```
### 4.3 参数
| 参数 | 默认 | 说明 |
|---|---|---|
| `user_id` / `charge_id` / `login_date_time` | 必填 | 后两个必须与 `UserLoginApi` 一致 |
| `charge_list` | 自动抓 | 当前票券列表(`GetUserChargeApi`),用来算购买后库存 |
| `user_data` | 自动抓 | 当前档案(`GetUserDataApi`),用来填 `playCount`/`playerRating` |
| `stock` | 现有 + 1 | 购买后该票的库存 |
| `price` | `TICKET_CREDIT_NUM[charge_id]` | = `ticket.creditNum` |
| `purchase_date` | 现在 | `YYYY-MM-DD HH:MM:SS` |
| `valid_date` | 由 `purchase_date` 推 | 现在 -4h +90d,取日期补 `04:00:00` |
| `play_count` / `player_rating` | 取自 `user_data` | |
| `confirm` | `config.UPSERT_CHARGELOG_CONFIRM`(False) | ★ 不开不发包 |
| `dry_run` | `False` | `True` = 只构造报文 |
---
## 五、买完之后票会怎样?
按源码,**买票接口自己就把 `userCharge`(含新库存)写进去了**,
不需要额外再发 `UpsertUserAll`。
真机的完整顺序是「买票 → 立刻用掉这一张」:
```
SetBuyPaidTicketData: stock++ (本地 +1)
RequestUserCharge: 发 stock(购买后的值)
RequestSuccess: stock-- (票被本局用掉了)
```
所以 `userChargeList` 里的 `stock` 平时大多是 **0** ——
真机 22:31 那次的 `userChargeList` 三条全是 `stock: 0`,就是这个原因。
> **本模块只负责「买」**,默认上报的是**购买后**的库存(现有 + 1)。
> 如果你要复刻「买完立刻用掉」的完整语义,买完再发一次
> `UpsertUserAll(..., extra_data={"userChargeList": [...]})` 把 `stock` 减回去。
---
## 六、★ 真机抓包对照(已修正,报文逐字节一致)
### 6.0 结论:唯一差异是 `purchaseDate` 少了一个 `.0`
真机抓包(`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":1,"clientId":"TESTCLIENT1"},
"userCharge":{"chargeId":2,"stock":1,"purchaseDate":"2026-10-06 13:20:10.0",
"validDate":"2027-01-04 04:00:00"},
"loginDateTime":1791292785}
```
**14 个字段逐项对照:**
| 字段 | 真机 | 修正前 | 结论 |
|---|---|---|---|
| `userChargelog.chargeId` | 2 | 2 | ✅ |
| `userChargelog.price` | **1** | 1(= `creditNum`) | ✅ **源码推导正确** |
| `userChargelog.purchaseDate` | **`2026-10-06 13:20:10.0`** | `2026-10-06 13:10:42` | ❌ **少 `.0`** |
| `userChargelog.playCount` | 1262 | 1262 | ✅ |
| `userChargelog.playerRating` | 15000 | 15000 | ✅ |
| `userChargelog.placeId` / `regionId` / `clientId` | 1000 / 5 / `TESTCLIENT1` | 同 | ✅ |
| `userCharge.chargeId` | 2 | 2 | ✅ |
| `userCharge.stock` | **1** | 1(**加 1 后**) | ✅ **源码推导正确** |
| `userCharge.purchaseDate` | `2026-10-06 13:20:10.0` | 少 `.0` | ❌ |
| `userCharge.validDate` | **`2027-01-04 04:00:00`** | 同 | ✅ **算法完全正确** |
| `loginDateTime` | 1791292785 | 同 | ✅ |
| 字段顺序 | — | 同 | ✅ |
**修正后逐字节比对:真机 328 字节 / 本工程 328 字节,完全相同。**
### 6.0.1 根因:`TimeManager.DateTimeFormat` 带一位小数
`Manager/TimeManager.cs:7,62-70`:
```csharp
public const string DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f"; // ← .f = 一位小数
public static string GetNowDateString()
=> GetDateString(GetNowUnixTime());
public static string GetDateString(long unixTime)
=> GetDateTime(unixTime).ToString("yyyy-MM-dd HH:mm:ss.f");
```
我原来用了 `%Y-%m-%d %H:%M:%S`(无小数),所以服务端 `returnCode=0`。
> ★ 两个格式别搞混:
> | 字段 | 格式 | 例子 |
> |---|---|---|
> | `purchaseDate` | `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` |
>
> **一个带小数一个不带**,这是最容易踩的地方。
> ★ 另一个细节:`GetNowTime()` 返回的是 **`DateTime.UtcNow`**,
> `GetDateString` 再 `Add(CSToffset)` —— 所以游戏里的日期串**恒是北京时间**,
> 与本机时区无关。脚本跑在非东八区机器上时 `datetime.now()` 会错,
> 所以本模块用 `now_cst()`(UTC+8)显式计算。
### 6.1 修正前的实跑记录(2026-10-06 13:10,已废弃)
```
[购买前] 当前票券 3 条:
chargeId=2 stock=0 validDate=2026-10-12 04:00:00
chargeId=3 stock=0 validDate=2026-12-31 04:00:00
chargeId=5 stock=0 validDate=2027-01-02 04:00:00
[购买前] 持有中的功能票:0 张 ← 安全检查通过,可以买
安全检查通过:账号当前没有持有任何功能票
票名 : 2倍チケット
库存 : 0 -> 1
price : 1(= ticket.creditNum)
purchaseDate: 2026-10-06 13:10:42
validDate : 2027-01-04 04:00:00
闸门已放行,开始上传购买记录 ...
★ 购买失败:returnCode=0 apiName=com.sega.maimai.api.UpsertUserChargelogApi
```
**账号无恙**(已只读复查):`userChargeList` 仍是原来 3 条、
`playCount=1262`、无封禁。
### 6.2 这次失败排除了什么
| 结论 | 依据 |
|---|---|
| **URL / 混淆名正确** | 服务端回了 `apiName=com.sega.maimai.api.UpsertUserChargelogApi` |
| **报文格式被接受** | 不是 0 字节响应、不是解密解压失败,是**业务层拒绝**(`returnCode=0`) |
| **不是闸门问题** | 闸门已放行,确实发了包 |
| **不是「已持票」问题** | 购买前确认 3 条票 stock 全是 0 |
| **就是 `purchaseDate` 格式** | 抓包对照后确认(见 6.0) |
### 6.3 剩下的未验证点
| # | 待验证 | 现状 |
|---|---|---|
| 1 | 买票后是否**还要**发 `UpsertUserAll` 才持久化库存 | 源码流程里没有;抓包只抓到这一条请求,**推测不需要** |
| 2 | `validDate` 是否会被服务端按本地时区重算 | 真机发的是 `2027-01-04 04:00:00`,与本工程算法一致 |
| 3 | 服务端是否对「有效期未过的同一 chargeId」有额外限制 | 真机在 `chargeId=2`(validDate 2026-10-12 未过)的情况下照样买了,**说明没有这个限制** |
### 6.4 抓包时确认过的时序
抓到的这一次只发了 **`UpsertUserChargelogApi` 一条写请求**,
请求体里同时带了 `userCharge`(新库存)和 `userChargelog`(购买记录)——
印证了「买票接口自己就把库存写进去了」。
---
## 六点五、★ 第二次实跑:`chargeId=5` 又被 `returnCode=0`(2026-10-06 13:41)
修完 `.f` 格式后再跑一次(这次买 **5 倍券**),**还是 `returnCode=0`**:
```
[购买前] 持有中的功能票:0 张 ← 安全检查通过
安全检查通过:账号当前没有持有任何功能票
票名 : 5倍チケット
库存 : 0 -> 1
price : 4(= ticket.creditNum)
purchaseDate: 2026-10-06 13:41:43.3 ← .f 格式已正确
validDate : 2027-01-04 04:00:00
闸门已放行,开始上传购买记录 ...
★ 购买失败:returnCode=0 apiName=com.sega.maimai.api.UpsertUserChargelogApi
```
### 6.5.1 根因:`chargeId=5` 根本没上架
只读查 `GetGameChargeApi`:
```json
{"length":2,"gameChargeList":[
{"orderId":1,"chargeId":2,"price":1,...}, ← 只有 2 和 3
{"orderId":2,"chargeId":3,"price":2,...}]}
```
**chargeId=5 不在清单里 → 服务端直接拒。**
### 6.5.2 源码里本来就有这道检查(我漏了)
`Manager/TicketManager.cs:137-150`:
```csharp
if (ticket.ticketKind.id == 1)
{
GameCharge[] gameChargeDataList = Singleton<OperationManager>.Instance.GetGameChargeDataList();
for (int i = 0; i < gameChargeDataList.Length; i++)
{
GameCharge gameCharge = gameChargeDataList[i];
if (gameCharge.chargeId == ticketId) // ★ 必须在清单里找到
{
...
RequestUserCharge(playerId); // 才发购买请求
return true;
}
}
}
return false; // ★ 找不到 -> 不买
```
**真机也是「先查清单,找到才买」** —— 我第一版实现直接跳到发请求,漏了这一步。
### 6.5.3 已补的守卫
`UpsertUserChargelog` 新增 `guard_purchasable=True`(默认开):
- 不传 `game_charge_list` 就自动调 `GetGameChargeApi`
- `charge_id` 不在清单里 → 抛 `ChargelogGuardError`,**未发包**
`buy_ticket.py` 更把这道检查放到了**扫码之前**(`GetGameChargeApi` 不需要登录),
不满足就直接退出 —— **不浪费二维码、不产生登录会话**:
```
当前可购买 chargeId:[2, 3]
chargeId=2 2倍チケット money price=1 有效期 2021-03-18 07:00:00 ~ 2029-12-31 00:00:00
chargeId=3 3倍チケット money price=2 有效期 2021-03-18 07:00:00 ~ 2029-12-31 00:00:00
[错误] chargeId=5(5倍チケット)**不在当前可购买清单里** —— 连登录都不做,直接退出。
```
### 6.5.4 本机台到底能买什么
| chargeId | 票名 | 上架 | `creditNum`(上报的 price) | money price |
|---|---|---|---|---|
| 2 | 2倍チケット | ✅ | 1 | 1 |
| 3 | 3倍チケット | ✅ | 2 | 2 |
| 4 | 4倍チケット | ❌ | 3 | — |
| 5 | 5倍チケット | ❌ | 4 | — |
| 6 | 6倍チケット | ❌ | 5 | — |
> ★ 上架清单是**机台/店铺配置**,不同店铺可能不一样。
> 每次买之前都该先看 `GetGameChargeApi`。
### 6.5.5 账号状态
两次失败都**没有写入任何东西**:`userChargeList` 三条 `stock` 全是 0。
服务端的 `returnCode=0` 是「干净拒绝」。
---
## 六点六、★★ 第三、四次实跑:`chargeId=2`(真机验证过的配置)**也失败**
### 6.6.1 做了什么
第 3 次(13:50,chargeId=3)和第 4 次(14:03,**chargeId=2**)分别补齐了:
| 补齐项 | 说明 |
|---|---|
| `number` 头 | 加了 `Ping()`,从 `"0"` 变成 `"61"`(真机 75) |
| 入场全量下载 | 登录后先拉 18~19 个节点,复刻真机 [14]~[60] |
| 买票前等待 | 加 5 秒,贴近真机 login→买票 的 25 秒节奏 |
**结果仍然是 `returnCode=0`。**
### 6.6.2 与真机报文的最终并排对比
第 4 次的报文 vs 真机抓包:
| 路径 | 真机 | 本工程 |
|---|---|---|
| 键集合 | — | **完全相同** |
| `userChargelog` 键序 | — | **完全相同** |
| `userCharge` 键序 | — | **完全相同** |
| `userChargelog.purchaseDate` | `2026-10-06 13:20:10.0` | `2026-10-06 14:03:14.6` |
| `userChargelog.playCount` | 1262 | 1263 |
| `userCharge.purchaseDate` | `2026-10-06 13:20:10.0` | `2026-10-06 14:03:14.6` |
| `loginDateTime` | 1791292785 | 1791295385 |
**唯一不同的就是这 3 个「当下时刻」类的值。** 其余 11 个字段逐字节相同。
### 6.6.3 已逐项排除的可能
| # | 假设 | 排除依据 |
|---|---|---|
| 1 | URL / 混淆名错 | `4411a4c8f7f5c0e95a3ac17f1746aad3` 与真机一致;服务端回正确 `apiName` |
| 2 | User-Agent 错 | 与真机逐字符相同 |
| 3 | Cookie 缺失 | 登录响应确实下发 `Set-Cookie: JSESSIONID=...`,Session 自动带 |
| 4 | 报文字段/顺序/类型错 | 与真机逐字节同构 |
| 5 | `purchaseDate` 格式错 | 已补 `.f`(`14:03:14.6`) |
| 6 | 票未上架 | `chargeId=2` 在 `GetGameChargeApi` 清单里 |
| 7 | 已持有功能票 | 购买前确认 3 条 `stock` 全 0 |
| 8 | `number` 头为 0 | 已加 `Ping()` → `"61"` |
| 9 | 缺少入场数据下载 | 已补(18~19 节点,0 失败) |
| 10 | 节奏太快 | 已加 5 秒等待 |
| 11 | `chargeId` 本身有问题 | **改用真机验证过的 `chargeId=2` 仍然失败** |
### 6.6.4 剩余假设
请求体已经被证明「结构上完全正确」,所以问题只可能在**请求之外**:
| # | 假设 | 怎么验证 |
|---|---|---|
| **A** | ★ **服务端有「购买频率/每日上限」规则** —— 账号在 **13:20 刚买过同一张票**,43 分钟后再买被拒 | **在真机上再买一次 2 倍券**:若真机也失败 → 规则成立,本工程实现正确;若真机成功 → 本工程有 bug |
| B | 服务端对**金钱相关接口**做了 TLS/HTTP 指纹校验(非游戏客户端特征) | 需要本地 MITM 代理对比完整 HTTP 请求头 |
| C | 服务端要求某个本工程不知道的前置状态 | 同上 |
**★ 假设 A 是最可能、也最容易验证的**:`returnCode=0` 只用了 **47 ms** 就返回,
像是命中了一条规则检查,而不是超时或异常。
---
## 七、安全闸门
| 调用方式 | 行为 |
|---|---|
| `UpsertUserChargelog(...)` | 抛 `ChargelogConfirmError`,**未发包** |
| `UpsertUserChargelog(..., dry_run=True)` | 只构造并返回报文 |
| `UpsertUserChargelog(..., confirm=True)` | 真实发送 |
全局开关:`config.UPSERT_CHARGELOG_CONFIRM = False`(改成 `True` 会永久放行,不建议)。