fix(huya): 结构体尾部字段容错解析(服务端新增 ZERO 字段不再打挂整个 RPC)

真机现象(2026-10-15,外部 CK 导入后查询角色):checkUserBindGameAccount 响应结构
尾部多了 tag6(ZERO 类型 0x6c),共享的 read_struct 读到非 STRUCT_END 直接抛
「期望 STRUCT_END, 实际 0x0c」,整个 RPC 返回 None —— 工作台「查询角色」永远失败。

- taf_protocol:read_struct 收尾改为按 TAF 语义跳过未建模的尾部字段(上限 32 个,
  防止解析错位时无限吞字节);新增 skip_to_struct_end()
- frame_decoder / shop_structs:抽出 decode_struct_bytes 与 ShopRawRsp.from_payload,
  WSS 与 HTTP 两条通道共用一份通用解码逻辑(wss_client 同步改用)

验证:tests/test_huya_struct_parse_resilience.py 用真机响应字节回归(含 tag6=ZERO 的
响应能解析出角色「泪雨全是谎言」);按 AGENTS.md 反向验证 —— 注掉
skip_to_struct_end() 即复现线上报错「期望 STRUCT_END, 实际 0x0c」,还原后恢复。
全量 pytest 通过。
This commit is contained in:
yml2213
2026-10-15 15:21:34 +08:00
parent 4ce30e5c26
commit a9d711a7e9
5 changed files with 133 additions and 29 deletions
@@ -0,0 +1,64 @@
"""TAF 结构体解析韧性:尾部未建模字段(服务端新增/ZERO)与通用响应回落。
回归来源(2026-10-15 真机):`checkUserBindGameAccount` 响应结构尾部多了 tag6
(ZERO 类型),旧解析直接抛 "期望 STRUCT_END, 实际 0x0c",导致整个 RPC 返回 None、
工作台「查询角色」永远失败。修法:按 TAF 语义跳过未建模的尾部字段。
"""
from core.huya.activity_structs import CheckUserBindGameAccountResp
from core.huya.shop_structs import ShopRawRsp
from core.huya.taf_protocol import TafInputStream, TafOutputStream, TafType
# 真机响应(checkUserBindGameAccount,UID 1199665170519,bActId 9271)
BIND_RESP_HEX = (
"0a0100c816002a0001100120013a060016002602717136004612e6b3aae6958fe6849fe"
"79a84e59b9ee5bf86560066000b4a06001612e6b3aae99ba8e585a8e698afe8b08ee8a8"
"802604e6898b51360be88bb9e69e9c28694f53294600560066000b526a2595806c0b36"
"0ce5928ce5b9b3e7b2bee88bb1460050070b"
)
def test_bind_response_with_zero_tail_field_parses():
stream = TafInputStream(bytes.fromhex(BIND_RESP_HEX))
tag, dtype = stream.read_head()
assert (tag, dtype) == (0, TafType.STRUCT_BEGIN)
resp = CheckUserBindGameAccountResp()
resp.read_from(stream)
assert resp.status == 200
assert resp.gameName == "和平精英"
assert resp.accountData.isBindAcount == 1
assert resp.accountData.isBindRole == 1
assert resp.accountData.gameAccount.nick == "泪敏感的回忆"
assert resp.accountData.gameRole.roleName == "泪雨全是谎言"
assert resp.accountData.gameRole.areaName == "手Q"
assert resp.accountData.gameRole.platName == "苹果(iOS)"
# 外层结构正常收尾(内层 tag6=ZERO 已由 read_struct 跳过)
_end_tag, end_type = stream.read_head()
assert end_type == TafType.STRUCT_END
def test_skip_to_struct_end_skips_zero_field():
output = TafOutputStream()
output.write_struct_begin(0)
output.write_int32(0, 1)
output.write_int32(6, 0) # ZERO 类型(0x6c)
output.write_struct_end()
stream = TafInputStream(output.get_bytes())
stream.read_head() # STRUCT_BEGIN
assert stream.read_int32(0) == 1
assert stream.skip_to_struct_end() == 1
_tag, dtype = stream.read_head()
assert dtype == TafType.STRUCT_END
def test_shop_raw_rsp_from_payload_flat_and_nested():
flat = ShopRawRsp.from_payload({"tag0": 200, "tag1": "success"})
assert (flat.code, flat.message) == (200, "success")
nested = ShopRawRsp.from_payload({"tag0": {"tag0": 200, "tag1": "成功"}})
assert (nested.code, nested.message) == (200, "成功")
assert ShopRawRsp.from_payload(None).code == 0