Files
Astral/docs/购买功能票流程.md
T
Txiaochen 41909b9169 docs: WaHappy 开发文档
孤儿分支,只含 docs/。内容包括:
- 项目总览 / 主脚本编写指南 / 日志指南
- 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条)
- 真机 Hook 对照报告 / 实跑验证记录
- 购买功能票流程(含被服务端拒绝的排查过程)
  · 扫码登录协议技术规范
2026-10-06 14:42:32 +08:00

18 KiB
Raw Blame History

购买功能票流程(登录 → 买票 → 登出)

对应 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。

★ 两个格式别搞混:

字段 格式 例子
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:

{"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)
入场全量下载 登录后先拉 1819 个节点,复刻真机 [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 会永久放行,不建议)。