# 购买功能票流程(登录 → 买票 → 登出) > 对应 `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.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` 会永久放行,不建议)。