Files
live-hub-py/tests/test_huya_points_shop_protocol.py
T
yml2213 f540dcfc29 feat(huya): 对齐积分中心开通链路(2026-10-13 抓包)
按 10.13「开通精英宝典/购买幸运币」抓包(m-shop.huya.com/points.html)对齐商城链路:

- WSS host 改为 launch.queryHttpDns 动态解析(失败回退抓包实测 host);握手 baseinfo
  只带 uid/guid/页面 UA/exp,Origin 与页面同源
- wsLaunch 改为带真实 lUid/sGuid;初始化补上 AUTH(0x0A) → getConfig →
  UpdateUserExps(0x17),并把 getConfig 下发的版本 map 原样回传(不回传则业务 RPC 无响应)
- 补齐新 RPC:getSupplierInfoV5 / acceptHyProtocolV5 / calcOrderPromotion /
  PlayMallUI.getMyPromotion|queryAllocation / wupui.* / huyauserui.getUserProfile
- 下单参数对齐:shopAppInfo.scene=7 与订单 src/orderScene=4 分离、orderType=6、
  bizType=5、gameCategoryId=507、extraParam.bizParams 标记 jifencenter、env 仅空点击
  序列、支付回调指向 m-shop.huya.com/points.html
- 开通链路抽到 services/huya_shop_flow.py,充值任务改为调用它

验证:新增 tests/test_huya_points_shop_protocol.py 与抓包逐字节比对(心跳、版本回传、
wsLaunch/getConfig/下单/支付帧与新 RPC 参数,共 10 条);按 AGENTS.md 做了反向验证
(把 jifencenter 改成 0 后该用例变红,还原后恢复)。ruff format --check 与全量 pytest 通过。
2026-10-15 14:56:30 +08:00

392 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""积分中心商城链路回归测试(2026-10-13「开通精英宝典 / 购买幸运币」抓包)。
抓包文件不入库,这里把关键帧的字节与字段直接固化在测试里;每条断言的来源
都写在注释里,改动协议时先核对抓包再改测试。
"""
import asyncio
import base64
import struct
from core.huya.frame_decoder import _decode_wup_body, _extract_wup
from core.huya.http_client import (
WSConnectParaInfo,
_extract_ws_hosts,
generate_ws_baseinfo,
)
from core.huya.shop_structs import shop_spu_number
from core.huya.taf_protocol import TafInputStream, TafOutputStream, TafType
from core.huya.wss_client import (
HuyaWssClient,
WssCommand,
WssMessage,
)
from core.huya.wup_protocol import WupResponse
UID = 1199668595232
GUID = "0a8995af9218ce6a340261946294a9e7"
COOKIE = f"udb_uid={UID}; yyuid={UID}; guid={GUID}"
PID = 1199650619883
# 抓包字面值(不要引用实现常量,否则断言会与实现一起漂移)
POINTS_UA_LITERAL = "webh5&0.0.1&websocket&&h5_/points.html"
TAIL_LITERAL = bytes.fromhex("2c36004c5c6600")
BIZ_PARAMS_LITERAL = '{ "jifencenter": 1 }'
# 抓包实测:RegisterGroup(live:0) 与 UpdateUserExps(版本 map) 的 body 逐字节一致
HEARTBEAT_BODY = bytes.fromhex("09000106066c6976653a3016002c36004c5c6600")
UPDATE_EXPS_BODY = bytes.fromhex("0800010605353836363416063130373236332c36004c5c6600")
CONFIG_VERSIONS = {"58664": "107263"}
class _FakeWs:
"""记录发出的帧,并立刻用预设 body 兑现 RPC future(离线跑完整链路)。"""
def __init__(self, client: HuyaWssClient, response: bytes = b""):
self.client = client
self.response = response
self.sent: list[bytes] = []
async def send(self, data: bytes):
self.sent.append(data)
while self.client.rpc_queue:
future = self.client.rpc_queue.popleft()
if not future.done():
future.set_result(self.response)
def last_body(self) -> bytes:
return WssMessage.decode(self.sent[-1]).body
def last_request(self) -> dict:
return _decode_wup_body(self.last_body())
def _points_client(response: bytes = b"") -> HuyaWssClient:
client = HuyaWssClient(logger=lambda _message: None)
client._points_mode = True
client.ws = _FakeWs(client, response)
return client
def _wup_response(t_rsp: bytes) -> bytes:
"""构造一个 tRsp 为给定结构的 WUP 响应(含 4 字节长度前缀)。"""
buffer = TafOutputStream()
buffer.write_head(0, TafType.MAP)
buffer.write_int32(0, 1)
buffer.write_string(0, "tRsp")
buffer.write_bytes(1, t_rsp)
output = TafOutputStream()
output.write_int16(1, 3)
output.write_int8(2, 0)
output.write_int32(3, 0)
output.write_int32(4, 1)
output.write_string(5, "mobileui")
output.write_string(6, "getConfig")
output.write_bytes(7, buffer.get_bytes())
output.write_int32(8, 0)
output.write_map(9, {})
output.write_map(10, {})
body = output.get_bytes()
return struct.pack(">I", 4 + len(body)) + body
def _config_rsp(versions: dict[str, str]) -> bytes:
"""mobileui.getConfig 响应:tag0 开关 map、tag1 版本 map(抓包实测)。"""
output = TafOutputStream()
output.write_struct_begin(0)
output.write_map(0, {})
output.write_map(1, versions)
output.write_struct_end()
return _wup_response(output.get_bytes())
def test_register_group_frame_matches_capture():
async def run():
client = _points_client()
await client.send_heartbeat()
message = WssMessage.decode(client.ws.sent[-1])
assert message.command == WssCommand.HEARTBEAT_SEND
assert message.sequence == 0x1D00000D
assert message.body == HEARTBEAT_BODY
asyncio.run(run())
def test_shop_ws_launch_carries_real_identity_and_points_ua():
async def run():
client = _points_client(_wup_response(b""))
await client.call_ws_launch_shop(UID, GUID)
message = WssMessage.decode(client.ws.sent[-1])
assert message.command == WssCommand.RPC_REQUEST
# 抓包:wsLaunch 帧 seq=0x1d000100,iRequestId=-1
assert message.sequence == 0x1D000100
wup = WupResponse()
wup.decode(_extract_wup(message.body))
assert wup.iRequestId == -1
decoded = _decode_wup_body(message.body)
assert (decoded["servant"], decoded["func"]) == ("launch", "wsLaunch")
t_req = decoded["tReq"]
# 抓包:lUid=真实 uid、sGuid=真实 guid、UA=h5_/points.html(不再是匿名)
assert t_req["tag0"] == UID
assert t_req["tag1"] == GUID
assert t_req["tag2"] == POINTS_UA_LITERAL
assert t_req["tag3"] == "HUYA&ZH&2052"
assert t_req["tag4"] == {
"tag0": "",
"tag1": "",
"tag2": "",
"tag3": "",
"tag4": "",
}
asyncio.run(run())
def test_get_config_uses_real_user_and_confirms_version_map():
async def run():
client = _points_client(_config_rsp(CONFIG_VERSIONS))
body = await client.call_get_config_shop(uid=UID, guid=GUID, cookie=COOKIE)
assert body is not None
assert client._config_versions == CONFIG_VERSIONS
decoded = client.ws.last_request()
assert (decoded["servant"], decoded["func"]) == ("mobileui", "getConfig")
t_req = decoded["tReq"]
# 抓包:getConfig 的 UserId 带真实 uid/guid/页面 UA/完整 Cookie
assert t_req["tag0"]["tag0"] == UID
assert t_req["tag0"]["tag1"] == GUID
assert t_req["tag0"]["tag3"] == POINTS_UA_LITERAL
assert GUID in t_req["tag0"]["tag4"]
assert t_req["tag2"] == "huya"
# UpdateUserExps 必须原样回传版本 map(抓包逐字节一致)
await client.send_confirm(client._config_versions)
assert client.ws.last_body() == UPDATE_EXPS_BODY
asyncio.run(run())
def test_create_order_v5_matches_points_capture():
async def run():
client = _points_client()
await client.create_order(
uid=UID,
guid=GUID,
cookie=COOKIE,
pid=PID,
spu_id="hy-8328015",
sku_id=7756811,
item_count=1,
)
message = WssMessage.decode(client.ws.sent[-1])
# 抓包:下单帧用 0x1d000108(其余业务帧 0x1d000107)
assert message.sequence == 0x1D000108
decoded = _decode_wup_body(message.body)
assert (decoded["servant"], decoded["func"]) == (
"shopMiddleUI",
"createOrderV5",
)
t_req = decoded["tReq"]
assert t_req["tag1"]["tag4"] == 7 # shopAppInfo.scene=7(appInfo 用 7)
assert t_req["tag3"] == PID
assert t_req["tag4"] == 7756811
assert t_req["tag5"] == 1
assert t_req["tag7"] == "hy-8328015"
assert t_req["tag8"] == "0"
assert t_req["tag10"] == 4 # src
assert t_req["tag12"] == 6 # orderType
assert t_req["tag13"]["tag4"] == BIZ_PARAMS_LITERAL
assert t_req["tag16"] == "yellowcarlist"
assert t_req["tag17"] == {"user_click_seq": "[]"}
assert t_req["tag18"] == 4 # orderScene
assert t_req["tag21"]["tag2"] == 1
assert t_req["tag30"] == 5
assert t_req["tag31"] == 507
asyncio.run(run())
def test_pay_order_submit_v5_callback_points_page():
async def run():
client = _points_client()
await client.pay_order_submit(
uid=UID,
guid=GUID,
cookie=COOKIE,
order_id=11339649,
pay_type=1,
pid=PID,
)
message = WssMessage.decode(client.ws.sent[-1])
assert message.sequence == 0x1D000108
decoded = _decode_wup_body(message.body)
assert (decoded["servant"], decoded["func"]) == (
"shopMiddleUI",
"payOrderSubmitV5",
)
t_req = decoded["tReq"]
assert t_req["tag2"] == 11339649
assert t_req["tag3"] == "Zfb"
assert t_req["tag4"] == "QrCode"
# 抓包:回调指向 m-shop.huya.com/points.html,不是 yaoguo 域名
assert t_req["tag5"].startswith(
"https://m-shop.huya.com/points.html#/consumer/paycallback?orderId=11339649"
)
assert t_req["tag6"] == {"user_click_seq": "[]"}
assert t_req["tag7"] == "null"
assert t_req["tag8"] == ""
asyncio.run(run())
def test_new_points_rpcs_match_capture():
async def run():
client = _points_client()
await client.get_supplier_info(uid=UID, guid=GUID, cookie=COOKIE)
decoded = client.ws.last_request()
assert (decoded["servant"], decoded["func"]) == (
"shopMiddleUI",
"getSupplierInfoV5",
)
assert decoded["tReq"]["tag2"] == 87401
await client.accept_hy_protocol(uid=UID, guid=GUID, cookie=COOKIE)
decoded = client.ws.last_request()
assert (decoded["servant"], decoded["func"]) == (
"shopMiddleUI",
"acceptHyProtocolV5",
)
assert decoded["tReq"]["tag2"] == 1
await client.calc_order_promotion(
uid=UID,
guid=GUID,
cookie=COOKIE,
pid=PID,
spu_id="hy-8328015",
sku_id=7756811,
)
decoded = client.ws.last_request()
assert (decoded["servant"], decoded["func"]) == (
"shopMiddleUI",
"calcOrderPromotion",
)
t_req = decoded["tReq"]
assert t_req["tag2"] == 8328015 # spuId 去前缀后的数字
assert t_req["tag3"] == 7756811
assert t_req["tag4"] == 1
assert t_req["tag5"] == PID
assert (t_req["tag7"], t_req["tag8"], t_req["tag9"]) == (1, 1, 1)
assert t_req["tag14"] == 8328015
assert t_req["tag15"] == 9815
await client.play_mall_query_allocation(uid=UID, guid=GUID, cookie=COOKIE)
decoded = client.ws.last_request()
assert (decoded["servant"], decoded["func"]) == (
"PlayMallUI",
"queryAllocation",
)
t_req = decoded["tReq"]
assert t_req["tag1"]["tag1"] == 1001
assert t_req["tag1"]["tag5"] == 7
assert t_req["tag1"]["tag6"] == "yellowcarlist"
assert t_req["tag2"] == "heping_baodian_credit_center_page"
await client.play_mall_get_my_promotion(
uid=UID,
guid=GUID,
cookie=COOKIE,
spu_id="hy-8328015",
sku_id=7756811,
price=3000,
)
decoded = client.ws.last_request()
assert (decoded["servant"], decoded["func"]) == (
"PlayMallUI",
"getMyPromotion",
)
goods = decoded["tReq"]["tag6"]
assert goods["tag0"] == 87401
assert goods["tag1"] == 8328015
assert goods["tag2"] == 7756811
assert goods["tag3"] == 3000
assert goods["tag6"] == 9815
await client.get_user_profile(uid=UID, guid=GUID, cookie=COOKIE)
decoded = client.ws.last_request()
assert (decoded["servant"], decoded["func"]) == (
"huyauserui",
"getUserProfile",
)
assert decoded["tReq"]["tag1"] == UID
await client.get_follow_wechat_status(uid=UID, guid=GUID, cookie=COOKIE)
decoded = client.ws.last_request()
assert (decoded["servant"], decoded["func"]) == (
"wupui",
"getUserFollowWeChatOfficialStatus",
)
assert decoded["tReq"]["tag1"] == 1
asyncio.run(run())
def test_confirm_frame_ends_with_capture_tail():
async def run():
client = _points_client()
await client.send_confirm({})
assert client.ws.last_body().endswith(TAIL_LITERAL)
asyncio.run(run())
def test_ws_baseinfo_matches_capture_shape():
raw = base64.b64decode(generate_ws_baseinfo(UID, GUID, POINTS_UA_LITERAL))
info = WSConnectParaInfo()
info.read_from(TafInputStream(raw))
# 抓包:握手 baseinfo 只有 uid/guid/UA/appSrc/exp 有值,cookie 与 traceId 为空
assert info.lUid == UID
assert info.sGuid == GUID
assert info.sUA == POINTS_UA_LITERAL
assert info.sAppSrc == "HUYA&ZH&2052"
assert info.sCookie == ""
assert info.sTraceId == ""
assert info.sExp == ""
with_exp = base64.b64decode(
generate_ws_baseinfo(UID, GUID, POINTS_UA_LITERAL, exp="58664.107263")
)
parsed = WSConnectParaInfo()
parsed.read_from(TafInputStream(with_exp))
assert parsed.sExp == "58664.107263"
def test_query_http_dns_hosts_extraction():
payload = (
b"\x1a\x09\x00\x04"
b"\x06\x17d35bf201-ws.va.huya.com"
b"\x06\x17d35bf373-ws.va.huya.com"
b"\x06\x17d35bf35c-ws.va.huya.com"
b"\x06\x17dcca159d-ws.va.huya.com"
)
assert _extract_ws_hosts(payload) == [
"d35bf201-ws.va.huya.com",
"d35bf373-ws.va.huya.com",
"d35bf35c-ws.va.huya.com",
"dcca159d-ws.va.huya.com",
]
assert _extract_ws_hosts(b"") == []
def test_shop_spu_number_strips_prefix():
assert shop_spu_number("hy-8328015") == 8328015
assert shop_spu_number("8327356") == 8327356
assert shop_spu_number("") == 0