买票功能暂停,归档全部有用信息以便将来恢复: - 协议事实(源码依据 + 真机 328 字节抓包原文) - 完整实验时间线(8 次失败)与 12 项已排除假设 - ★ 同一账号对照实验证明差异在客户端(机台能买、脚本不能买) - 别人的可用实现(截图转录)+ 12 项差异对照 + 6 个疑惑 - 账号风险提示、恢复工作的步骤与命令
20 KiB
购买功能票 —— 存档与交接文档
状态:暂停(2026-10-06) 暂停原因:多次失败购买后主账号疑似被服务端拉黑;实现仍未跑通。 本文把「从开始做买票 → 到暂停」之间的全部有用信息归档, 目的是将来恢复这项工作时能 5 分钟上手,不用重新踩一遍。
〇、30 秒速览
- 协议已完全搞清楚 —— 源码逐行 + 真机抓包 328 字节原文都有
- 报文已做到与真机逐字节一致,但服务端仍回
returnCode=0 - 同一账号对照实验证明:机台能买、脚本不能买 → 差异在客户端,不是账号/服务端
- 差异最可能在 HTTP 层(请求头 / Cookie / 连接特征),不在报文体
- 暂停前最后动作:给 MelonLoader Mod 加了「完整请求头 + 响应头 dump」,等一次真机抓包
- ⚠️ 恢复工作前先确认账号没被拉黑(见第八节)
一、结论速览
| 项 | 状态 |
|---|---|
| 接口名 / 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# 声明)
{
"userId": <ulong>,
"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:
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 字节)
{"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}
响应:
{"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#<userId>'
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 报文构造
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 调用方流程
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 ★ 我的疑惑(恢复工作时优先解答)
-
loginDatevsloginDateTime(最重要)- 源码
UserChargelogRequestVO.cs声明的是public long loginDateTime; - 真机抓包发的是
"loginDateTime":1791159576(数字) - 他们发的是
"loginDate"(字符串),而且说能成功 - 三种可能:
a. 服务端不校验这个字段 → 那我的
loginDateTime也不是问题 b. 服务端认的是loginDate(旧字段名) c. 他们的sdgb_api内部做了改名(把loginDate→loginDateTime) - 必须先搞清楚这一条,否则会一直在错的方向排查
- 源码
-
sdgb_api(data, apiName, cookie)到底做了什么?(最大的黑盒)- 是否补
MaimaiChn后缀?是否做 md5 混淆? - 请求头怎么设的?(
Accept-Encoding?number?Mai-Encoding?Content-Type?) - cookie 是怎么放进请求的(header 直写?cookie jar?)
- 加解密实现是否一致
- ★ 他们的成功很可能就藏在这里
- 是否补
-
Cookie 显式传递的含义
- 他们从登录结果里取出 cookie,手工传给每个请求
- 这个 cookie 是
Set-Cookie原样,还是提取出的JSESSIONID=...? - 和
requests.Session自动管理的有没有不同? (比如:他们可能把整个 Set-Cookie 串塞进Cookie:头)
-
60 秒等待是必需的吗?
- 真机是 25 秒,他们用 60 秒 —— 是协议要求还是保险起见?
-
stock硬编码 1- 如果账号已有该票(
stock=1),他们还是发 1 - 那「已持票不能买」的规则到底存不存在?(我加守卫是基于用户的警告)
- 如果账号已有该票(
-
validDate在凌晨会差一天- 他们的算法在 00:00~04:00 之间会比我多算一天
- 谁对?(源码支持我的,但他们的能跑通)
八、⚠️ 账号状态与风险
| 账号 | 状态 | 备注 |
|---|---|---|
| A(主号) | ⚠️ 疑似被拉黑 | 6 次失败购买后,机台与脚本都买不了 |
| B(测试号) | 暂未见异常 | 机台成功过;脚本失败 1 次 |
风险提示:
- 不要反复重试买票 —— 每次都是一次真实的服务端交互,可能正是失败次数导致拉黑
- 恢复工作前先用机台确认账号还能不能买
- 优先用非主力账号做实验
- 已有的三道守卫(可购买清单 / 已持票 / 副作用闸门)不要为了「试通」而关掉
九、恢复工作时怎么做
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)
构建与部署:
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 拿到日志后怎么比
- 找
UpsertUserChargelogApi那一段 - 对比请求头集合:多了什么?少了什么?
- 重点:
Expect、Accept-Encoding、Cookie、Connection、以及头的顺序
- 重点:
- 对比
Cookie头:真机发的是JSESSIONID=...还是别的? - 对比登录响应的
Set-Cookie:和我 Session 里存的是不是同一个 - 如果请求头完全一致 → 转向 TLS 指纹 / MITM 抓完整 TCP 层
9.4 验证买票是否成功的命令
# 预检(不发包)
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,买票功能暂停时。