孤儿分支,只含 docs/。内容包括: - 项目总览 / 主脚本编写指南 / 日志指南 - 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条) - 真机 Hook 对照报告 / 实跑验证记录 - 购买功能票流程(含被服务端拒绝的排查过程) · 扫码登录协议技术规范
18 KiB
购买功能票流程(登录 → 买票 → 登出)
对应
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# 声明)
{
"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:
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:
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)
# 预检:只看报文,零副作用
python buy_ticket.py "<二维码>" --charge-id 2
# 先看看现在有什么票、能买什么
python buy_ticket.py "<二维码>" --list
# ★ 真实购买
python buy_ticket.py "<二维码>" --charge-id 2 --send
4.2 在代码里调
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 字节):
{"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:
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。
★ 两个格式别搞混:
字段 格式 例子 purchaseDateyyyy-MM-dd HH:mm:ss.f2026-10-06 13:20:10.0validDateyyyy-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:
{"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:
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 |
| 买票前等待 | 加 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 会永久放行,不建议)。