From a9d711a7e9ea52bd2d81375d2e7efe345e964aeb Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 15 Oct 2026 15:21:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(huya):=20=E7=BB=93=E6=9E=84=E4=BD=93?= =?UTF-8?q?=E5=B0=BE=E9=83=A8=E5=AD=97=E6=AE=B5=E5=AE=B9=E9=94=99=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=EF=BC=88=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20ZERO=20=E5=AD=97=E6=AE=B5=E4=B8=8D=E5=86=8D=E6=89=93?= =?UTF-8?q?=E6=8C=82=E6=95=B4=E4=B8=AA=20RPC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机现象(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 通过。 --- core/huya/frame_decoder.py | 19 +++++++ core/huya/shop_structs.py | 18 ++++++ core/huya/taf_protocol.py | 25 ++++++++- core/huya/wss_client.py | 36 +++--------- tests/test_huya_struct_parse_resilience.py | 64 ++++++++++++++++++++++ 5 files changed, 133 insertions(+), 29 deletions(-) create mode 100644 tests/test_huya_struct_parse_resilience.py diff --git a/core/huya/frame_decoder.py b/core/huya/frame_decoder.py index 3b6292c..74f9afb 100644 --- a/core/huya/frame_decoder.py +++ b/core/huya/frame_decoder.py @@ -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): diff --git a/core/huya/shop_structs.py b/core/huya/shop_structs.py index fc3247f..317fe4a 100644 --- a/core/huya/shop_structs.py +++ b/core/huya/shop_structs.py @@ -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} diff --git a/core/huya/taf_protocol.py b/core/huya/taf_protocol.py index e50d73c..2d4103a 100644 --- a/core/huya/taf_protocol.py +++ b/core/huya/taf_protocol.py @@ -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}") diff --git a/core/huya/wss_client.py b/core/huya/wss_client.py index 920d691..c03388f 100644 --- a/core/huya/wss_client.py +++ b/core/huya/wss_client.py @@ -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, diff --git a/tests/test_huya_struct_parse_resilience.py b/tests/test_huya_struct_parse_resilience.py new file mode 100644 index 0000000..6e81c29 --- /dev/null +++ b/tests/test_huya_struct_parse_resilience.py @@ -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