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