Files
Astral/docs/购买功能票_存档与交接.md
Txiaochen ddacc97b18 docs: 新增《购买功能票_存档与交接》
买票功能暂停,归档全部有用信息以便将来恢复:
- 协议事实(源码依据 + 真机 328 字节抓包原文)
- 完整实验时间线(8 次失败)与 12 项已排除假设
- ★ 同一账号对照实验证明差异在客户端(机台能买、脚本不能买)
- 别人的可用实现(截图转录)+ 12 项差异对照 + 6 个疑惑
- 账号风险提示、恢复工作的步骤与命令
2026-10-06 16:49:41 +08:00

20 KiB
Raw Permalink Blame History

购买功能票 —— 存档与交接文档

状态:暂停(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# 声明)

{
  "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 ★ 我的疑惑(恢复工作时优先解答)

  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)

构建与部署:

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 验证买票是否成功的命令

# 预检(不发包)
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,买票功能暂停时。