孤儿分支,只含 docs/。内容包括: - 项目总览 / 主脚本编写指南 / 日志指南 - 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条) - 真机 Hook 对照报告 / 实跑验证记录 - 购买功能票流程(含被服务端拒绝的排查过程) · 扫码登录协议技术规范
478 lines
18 KiB
Markdown
478 lines
18 KiB
Markdown
# 购买功能票流程(登录 → 买票 → 登出)
|
||
|
||
> 对应 `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` 会永久放行,不建议)。
|