From ddacc97b188896d179d30903343e3dc254f4faa2 Mon Sep 17 00:00:00 2001 From: Txiaochen Date: Tue, 6 Oct 2026 16:49:41 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E3=80=8A=E8=B4=AD?= =?UTF-8?q?=E4=B9=B0=E5=8A=9F=E8=83=BD=E7=A5=A8=5F=E5=AD=98=E6=A1=A3?= =?UTF-8?q?=E4=B8=8E=E4=BA=A4=E6=8E=A5=E3=80=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 买票功能暂停,归档全部有用信息以便将来恢复: - 协议事实(源码依据 + 真机 328 字节抓包原文) - 完整实验时间线(8 次失败)与 12 项已排除假设 - ★ 同一账号对照实验证明差异在客户端(机台能买、脚本不能买) - 别人的可用实现(截图转录)+ 12 项差异对照 + 6 个疑惑 - 账号风险提示、恢复工作的步骤与命令 --- docs/购买功能票_存档与交接.md | 498 ++++++++++++++++++++++++++++++++++ 1 file changed, 498 insertions(+) create mode 100644 docs/购买功能票_存档与交接.md diff --git a/docs/购买功能票_存档与交接.md b/docs/购买功能票_存档与交接.md new file mode 100644 index 0000000..e6f0ef5 --- /dev/null +++ b/docs/购买功能票_存档与交接.md @@ -0,0 +1,498 @@ +# 购买功能票 —— 存档与交接文档 + +> **状态:暂停(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,买票功能暂停时。*