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:
@@ -215,6 +215,25 @@ def _decode_wup_body(body: bytes) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def decode_struct_bytes(data: bytes) -> dict:
|
||||
"""把裸结构体字节解成通用 dict(``tagN`` → 值);非结构体或解析失败返回 {}。
|
||||
|
||||
WSS 与 HTTP 两条通道都要用「未逐字段建模」的通用解码,统一放在这里,
|
||||
避免两边各写一份。
|
||||
"""
|
||||
if not isinstance(data, bytes) or not data:
|
||||
return {}
|
||||
ins = TafInputStream(data)
|
||||
try:
|
||||
_tag, dtype = ins.peek_head()
|
||||
if dtype != TafType.STRUCT_BEGIN:
|
||||
return {}
|
||||
ins.read_head()
|
||||
return _decode_taf_struct(ins)
|
||||
except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
||||
return {}
|
||||
|
||||
|
||||
def _truncate(obj, max_str=80):
|
||||
"""截断过长的值,使日志紧凑"""
|
||||
if isinstance(obj, str):
|
||||
|
||||
@@ -1128,6 +1128,24 @@ class ShopRawRsp:
|
||||
node = node.get(key)
|
||||
return node
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: dict | None) -> "ShopRawRsp":
|
||||
"""从通用解码结果构造:code/message 支持扁平与嵌套(tag0.tag0/tag0.tag1)两种形态。"""
|
||||
data = payload or {}
|
||||
code = 0
|
||||
message = ""
|
||||
head = data.get("tag0")
|
||||
if isinstance(head, int):
|
||||
code = int(head)
|
||||
if isinstance(data.get("tag1"), str):
|
||||
message = data["tag1"]
|
||||
elif isinstance(head, dict):
|
||||
if isinstance(head.get("tag0"), int):
|
||||
code = int(head["tag0"])
|
||||
if isinstance(head.get("tag1"), str):
|
||||
message = head["tag1"]
|
||||
return cls(code=code, message=message, payload=data)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"code": self.code, "message": self.message, "payload": self.payload}
|
||||
|
||||
|
||||
@@ -431,6 +431,28 @@ class TafInputStream:
|
||||
result.append(self._read_value(it, item_reader))
|
||||
return result
|
||||
|
||||
#: 结构体尾部允许跳过的未解析字段上限(解析错位时避免无限吞字节)
|
||||
STRUCT_TAIL_SKIP_LIMIT = 32
|
||||
|
||||
def skip_to_struct_end(self) -> int:
|
||||
"""跳过结构体尾部未建模的字段,返回跳过的字段数(STRUCT_END 不消费)。
|
||||
|
||||
服务端会给结构体追加新字段(常见是值为 0 的 ZERO 类型:例如
|
||||
checkUserBindGameAccount 响应在 tag5 之后多出 tag6=ZERO)。读取方只消费
|
||||
自己认识的字段,其余按 TAF 语义跳过,由 read_struct 统一收尾 ——
|
||||
否则一个新增字段就会让整个 RPC 解析失败、上层读到 None。
|
||||
"""
|
||||
skipped = 0
|
||||
while skipped < self.STRUCT_TAIL_SKIP_LIMIT:
|
||||
pos = self.buf.tell()
|
||||
_tag, dtype = self.read_head()
|
||||
if dtype == TafType.STRUCT_END:
|
||||
self.buf.seek(pos)
|
||||
return skipped
|
||||
self.skip_field(dtype)
|
||||
skipped += 1
|
||||
raise ValueError("结构体尾部未解析字段过多,疑似解析错位")
|
||||
|
||||
def read_struct(self, tag: int, struct_class, required: bool = False):
|
||||
"""读取结构体,struct_class 需有无参构造 + read_from"""
|
||||
found = self._find_tag(tag, required)
|
||||
@@ -440,7 +462,8 @@ class TafInputStream:
|
||||
raise ValueError(f"期望 struct, 实际 0x{found[1]:02x}")
|
||||
obj = struct_class()
|
||||
obj.read_from(self)
|
||||
# 消费 STRUCT_END
|
||||
# 尾部未建模字段先跳过,再消费 STRUCT_END
|
||||
self.skip_to_struct_end()
|
||||
_t, dt = self.read_head()
|
||||
if dt != TafType.STRUCT_END:
|
||||
raise ValueError(f"期望 STRUCT_END, 实际 0x{dt:02x}")
|
||||
|
||||
+8
-28
@@ -26,7 +26,12 @@ from typing import Any, cast
|
||||
import websockets
|
||||
|
||||
from .cookie_utils import cookie_pairs, normalize_cookie_pairs
|
||||
from .frame_decoder import _decode_taf_struct, _truncate, format_wss_log
|
||||
from .frame_decoder import (
|
||||
_decode_taf_struct,
|
||||
_truncate,
|
||||
decode_struct_bytes,
|
||||
format_wss_log,
|
||||
)
|
||||
from .taf_protocol import TafInputStream, TafOutputStream, TafStruct, TafType
|
||||
from .wup_protocol import WupRequest, WupResponse
|
||||
|
||||
@@ -817,39 +822,14 @@ class HuyaWssClient:
|
||||
except Exception: # noqa: BLE001 响应解析失败按空结构处理
|
||||
return {}
|
||||
data = wup_resp.newdata.get("tRsp") or wup_resp.newdata.get("tResp")
|
||||
if not isinstance(data, bytes) or not data:
|
||||
return {}
|
||||
ins = TafInputStream(data)
|
||||
try:
|
||||
_tag, dtype = ins.peek_head()
|
||||
if dtype != TafType.STRUCT_BEGIN:
|
||||
return {}
|
||||
ins.read_head()
|
||||
return _decode_taf_struct(ins)
|
||||
except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
||||
return {}
|
||||
return decode_struct_bytes(data) if isinstance(data, bytes) else {}
|
||||
|
||||
@classmethod
|
||||
def _raw_rsp_from_body(cls, body: bytes):
|
||||
"""响应 → ShopRawRsp(code/message + 通用解码结构)。"""
|
||||
from .shop_structs import ShopRawRsp
|
||||
|
||||
payload = cls._decode_rsp_dict(body)
|
||||
code = 0
|
||||
message = ""
|
||||
head = payload.get("tag0")
|
||||
if isinstance(head, int):
|
||||
# 扁平响应(acceptHyProtocolV5 / createOrderV5 等)
|
||||
code = int(head)
|
||||
if isinstance(payload.get("tag1"), str):
|
||||
message = payload["tag1"]
|
||||
elif isinstance(head, dict):
|
||||
# 嵌套响应(PlayMallUI.getMyPromotion 的 tag0.tag0/tag0.tag1)
|
||||
if isinstance(head.get("tag0"), int):
|
||||
code = int(head["tag0"])
|
||||
if isinstance(head.get("tag1"), str):
|
||||
message = head["tag1"]
|
||||
return ShopRawRsp(code=code, message=message, payload=payload)
|
||||
return ShopRawRsp.from_payload(cls._decode_rsp_dict(body))
|
||||
|
||||
async def call_rpc_raw(
|
||||
self,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user