外观
附录 G · 端点矩阵与数据复用
附录 F 回答了"某个端点长什么样"。这一页回答另一类问题:官服到底有几个端点、各自用什么方法、哪些数据结构被反复复用。
方法同上:登录后用 JWT 把官服自己下发的 endpoints 清单逐个打一遍(而不是猜路径),再对各结构做字段集交叉比对。
1. endpoints 是随登录态增长的
同一个 GET /,带不带 JWT 拿到的端点表不一样:
| 无 JWT | 带 JWT | |
|---|---|---|
| 端点个数 | 15 | 22 |
my_player / my_items / my_draft | null | 填上了(/players/<id>、/items/<id>、/draft/<id>) |
campaign.* 六件套 | 不存在 | campaign、campaign.reset、campaign.upgrade、campaign.strategy、campaign.victory、campaign.abort_victory |
两点值得注意:
- 键名里带点号:
campaign.abort_victory这样的 key 直接出现在 JSON 里(客户端大概按前缀切分后映射到枚举)。自建服务若要复刻,照抄这种命名即可。 campaign族只在登录后出现:未登录时连 key 都没有,不是值为null。写服务端时"按登录态裁剪端点表"是官方行为,不是可选项。
2. current_user 就是 JWT 的解码结果
带 JWT 时 GET / 返回的 current_user 有 15 个字段,与令牌 claim 逐一对应、完全一致:
text
client_id, exp, external_id, iat, identity_id, iss, jti,
language, payment, player_id, provider, roles, tier, user_id, user_name这解释了两件事:为什么服务端只需要一个 token 就能渲染出用户信息;以及为什么"exp 填用户 ID"这种怪做法能过——客户端读的是解码后的字段,不是去校验它。
3. 端点矩阵(实测)
| 端点 | 方法 | 状态 | 返回形态 | 备注 |
|---|---|---|---|---|
/ | GET | 200 | {build_info, current_user, endpoints, host_info, server_time, service_name, tenant_name, tier_name} | 端点表随登录态变化 |
/session | POST | 200 | 约 70 个扁平字段 | 见附录 F |
/config | GET | 200 | {xserver_closed, xserver_closed_header, forgot_password_url} | 唯一配置口 |
/players/{id} | GET | 405 | — | my_player 是写接口,不能 GET |
/players | GET | 405 | — | 集合也不可 GET(搜索应是 POST) |
/players/{id}/decks | GET | 200 | 裸数组 [5] | 元素字段见第 4 节 |
/players/{id}/library | GET | 200 | {cards, new_cards} | 条目主键 card_type |
/players/{id}/packs | GET | 200 | [] | 空集合 |
/players/{id}/achievements | GET | 200 | [] | 空集合 |
/players/{id}/dailymissions | GET | 200 | {missions} | missions[]:canceled, completed_date, counter, create_date, details, id, mission_id, modify_date, player_id, slot |
/items/{id} | GET | 200 | {date, equipped_items, items} | items[]:cnt, details, item_id |
/draft/ | GET | 200 | {cost, rewards} | 抽卡赛元数据 |
/draft/{id} | GET | 200 | {cards, losses, status, wins} | cards[]:card_count, total_cards |
/matches/v2/(未匹配) | GET | 200 | null | 见第 6 节 |
/matches(未匹配) | GET | 200 | null | 同上 |
/lobbyplayers | GET | 200 | [] | 大厅玩家列表 |
/tourney/ | GET | 200 | {all_knockout_tourneys} | 元素:end_date, id, name, rules_json_str, start_date |
/store/ | GET | 200 | 空体(0 字节) | 不是 JSON,就是什么都没有 |
/store/v2/ | GET | 200 | {alwaysFeatured, currency, groups, message, status, ts} | groups[]:endDate, group, groupId, hidden, offers, startDate;offers[] 25 字段(offerId, offerName, gold, diamonds, real, discount, entitlementName, slotType, slotValue, …) |
/store/txn | GET | 200 | {campaigns, cards, decks, diamonds, draft_admissions, equipment, gold, packs, resources} | 交易流水聚合 |
/campaign/{id} | GET | 200 | [] | 战役进度(本账号为空) |
/campaign/{id}/{reset,upgrade,strategy,victory,abort_victory} | — | 405 | — | 全是写操作,未触发 |
405 比 404 有信息量
405 Method Not Allowed 说明路由存在、只是方法不对;404 说明路径根本不存在(如 /.com/config)。排障时先分清这两者,能省掉一半猜测。
4. 复用证据:字段集完全相同的结构对
对每个结构的字段名集合两两求交,以下是完全一致的(即"同一批对象被换了地方再发一次"):
| A | B | 结论 |
|---|---|---|
/session → decks.headers[] | GET /players/{id}/decks[] | 11 个字段逐一相同(ally_faction, card_back, create_date, deck_code, favorite, id, last_played, main_faction, modify_date, name, player_id)——会话把卡组列表内联了,端点只是同一批对象换个容器 |
/session → all_knockout_tourneys[] | GET /tourney/ 的元素 | 同为 {end_date, id, name, rules_json_str, start_date},会话内联了赛事 |
/session → new_cards | library.new_cards | 同名同用途(两处都为空,只能确认名称与位置) |
view_offers.groups[] | view_offers.alwaysFeatured[] | 同一份 payload 内复用同一个 group 结构 |
/session → current_user | JWT claim 集 | 15 字段完全一致(第 2 节) |
实践含义:服务端不必为 /session 和各个子端点各写一套序列化——同一批 DTO 内联到哪里都一样。反过来说,只要改变其中一个结构的字段,客户端会在它出现的每个位置同时受影响。
5. 同名不同形:最容易踩的六个键
用户说的"不同端点不同格式"主要体现在同一个键名在不同端点里是不同类型:
| 键 | 形态 A | 形态 B |
|---|---|---|
decks | /session → 对象 { "headers": [ … ] } | GET /players/{id}/decks → 裸数组 [ … ] |
server_time | /session → 2026.09.25-06.03.07(点分) | / → 2026-09-25T06:00:00.420343Z(ISO 6 位小数) |
cards | library.cards = 卡牌行数组 | transactions.cards = 数量;my_draft.cards = {card_count, total_cards} |
id | 卡组 id = 卡组数字 ID | 卡牌行里官服没有 id(只有 player_id);私服(fyserver)会多回一个数字 id——客户端两种都不依赖 |
date | my_items.date = 整份物品的时间戳 | missions[].create_date / completed_date = 单条任务时间 |
currency | /session.currency = "USD" | /store/v2/.currency = "USD"(实测两者都是字符串,未发现差异) |
decks 的两种形态是最典型的坑
/session 里必须包一层 {"headers": [...]},独立端点则必须是裸数组。同一批对象、两种容器——这类差异只能靠实测发现,无法从命名推断。
6. 空响应的三种语义(实测原文)
"没有数据"在这个 API 里有三种写法,客户端三种都认,但含义不同:
| 原文 | 出现位置 | 含义 |
|---|---|---|
[] | /players/{id}/packs、/achievements、/lobbyplayers、/campaign/{id} | 空集合 |
null | /matches/v2/、/matches | JSON null 字面量(未匹配) |
| (0 字节) | /store/ | 空体,连 JSON 都不是 |
纠正:未匹配时官服返回的是 JSON null,不是字符串 "null"
正文第 7 章与附录 D 曾断言"必须返回字符串 null,JSON null 会让客户端卡在排队转圈"。官服实测返回的正是 JSON null(4 字节 null),而客户端在官服上工作正常——所以两种都能过。真正的禁忌只有一个:别返回 {} 或 []。附录 D 的断言已据此更正。
7. 对局内端点(真机抓包 + 官服实测)
第 3 节的矩阵只覆盖只读接口。对局内的端点来自两条独立证据:真机客户端的完整调用序列(附录 H)与官服实测。
| 端点 | 方法 | 状态 | 返回形态 | 备注 |
|---|---|---|---|---|
/singleplayerlobby | POST | 200 | 开局载荷 | 人机进局(正文第 13 章) |
/matches/v2/ | GET | 200 | 开局载荷 | 顶层只有 match_and_starting_data;私服会多一个 local_subactions |
/matches/v2/{id} | GET | 200 | 裸文本 running / finished | 保活 兼"我方载入完成"信号 |
/matches/v2/reconnect | GET | 200 | 裸文本 null | 无可重连对局 |
/matches/v2/{id}/actions | PUT | 200 | {actions, match, opponent_polling} | 轮询;无新动作时 actions 整个省略;match 只有 3 个字段 |
/matches/v2/{id}/actions | POST | 200 | OK(纯文本) | 提交;官服按真机格式也回 400 ACTION_ERROR(门槛未解,见第 13 章第九节) |
/matches/v2/{id}/mulligan | POST | 200 | {ai_error, deck, replacement_cards} | 换牌;PUT 返回 405 |
/matches/v2/{id}/mulligan/{location} | GET | 200 | 换牌结果,未换牌时是空对象 {} | location = left / right |
/matches/v2/{id} | PUT | 200 | OK | 会话动作({a: 包},包内 {side, action, value}):投降/结束 |
/matches/v2/{id}/post | GET | 200 | {faction, winner} | 赛后面板;faction 首字母大写(来源:私服抓包) |
三个"看着像 JSON 其实不是"的响应
GET /matches/v2/{id}、GET /matches/v2/reconnect 返回的是裸文本(running、null),POST .../actions 返回 OK——都不是 JSON。写客户端兼容层时别一律 json.loads。
8. 客户端会调、但私服没实现的端点(真机抓包)
这张表来自真机客户端打到自建私服的 404 清单——它们是"客户端期望存在"的证据,不代表官服一定有:
| 端点 | 私服 | 官服 | 客户端反应 |
|---|---|---|---|
/players/{id}/achievements | 404 | 200 [](空集合) | 忽略,继续 |
/players/{id}/dailymissions | 404 | 200 {missions} | 忽略,继续 |
/players/{id}/packs | 404 | 200 [] | 忽略,继续 |
/store/txn/dlc | 404 | 未实测 | 忽略,继续 |
/players/{codec 包} | 404 | 未实测 | 忽略,继续(PUT,accept-eula 等玩家事件;玩家标识编码在 URL 路径里) |
404 不致命,但会浪费时间
上表这些 404 不会让客户端崩——它会继续往下走。所以排查"进不去游戏"时别盯着 404 看,真正卡住流程的是返回值形态不对的端点(比如 /matches/v2/ 该给 null 却给了 [])。
9. 本页未覆盖
保持边界清楚——以下都没有实测,因为它们会改动账号数据、需要更长对局,或仍无解:
| 项 | 原因 |
|---|---|
campaign.* 的五个写操作 | 会推进/重置战役进度 |
/store/v2/txn(购买)与 /email/set | 会产生真实交易 |
| 官服动作提交的格式门槛 | 按真机格式提交仍被 400 拒,见第 13 章第九节 |
出牌/攻击类动作的 action_data 字段 | 现有真机样本只有 4 种动作,需要一局更长的对局 |
heartbeat 的实际副作用 | 会置在线状态,为保持只读而跳过 |
/players 的 POST(搜索) | 未验证参数形态 |