Files
live-hub-py/tests/test_huya_points_query.py
T
yml2213 905a7ef22c fix(huya): 查询积分改走积分中心新接口(含成长等级)
旧活动接口 webActUI.getUserScore 对任何 sid 都返回 603「活动已结束」
(接口已下线,与工作台 SID/活动配置无关),刷新积分只会报错。

- 协议层新增两个 RPC(WSS + HTTP 双侧同签名):
  shopMiddleUI.getRedeemNeedGoods(tag2=当前积分、tag3=兑换门槛,
  兑换商品号 POINTS_REDEEM_GOODS_NUM=8315982,传错直接 500)
  与 GameBizUI.getGrowLevelUserInfo(成长值/成长等级/昵称/头像)
- 新增结构体 GetRedeemNeedGoodsReq / GetGrowLevelUserInfoReq,
  与 HuyaEliteWssSession 透传
- 服务层 _execute_query_points 主源改为积分中心接口,结果带
  points/redeem_threshold/grow_level/grow_value,并同步 account.points;
  接口无响应时才回退旧活动接口并如实报告其 603 返回
- 开通支付成功后的积分刷新同样改走积分中心接口
- 前端任务页积分结果优先读 points(兼容旧记录的 available_score),
  并在有成长等级时一并展示
- 新增 tests/test_huya_points_query.py:真机响应字节回归 + 请求参数
  字面量断言 + 服务层积分来源/回退语义(已做反向验证)

真机验证:HTTP 通道取到积分 280 / 门槛 20 / 成长等级 3
2026-10-15 15:32:46 +08:00

296 lines
10 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-15 真机实测)。
背景:旧活动接口 ``webActUI.getUserScore`` 对任何 sid 都返回
603「活动已结束」(该接口已下线,与工作台里的 SID/活动配置无关);
积分中心用 ``shopMiddleUI.getRedeemNeedGoods`` 查积分:
``tag2``=当前积分、``tag3``=兑换门槛。下面固化的响应字节来自真机 HTTP 通道。
抓包/真机字面值直接写在测试里(不引用实现常量),避免断言跟着实现漂移。
"""
import asyncio
from core.huya.activity_structs import GetUserScoreResp
from core.huya.frame_decoder import _decode_wup_body, decode_struct_bytes
from core.huya.http_client import HuyaHttpClient
from core.huya.shop_structs import ShopRawRsp
from core.huya.wss_client import HuyaWssClient, WssMessage
from web.backend.models import HuyaAccount
from web.backend.services.huya_runner import HuyaBatchRunner
UID = 1199665170519
GUID = "0a8995af9218ce6a340261946294a9e7"
COOKIE = f"udb_uid={UID}; yyuid={UID}; guid={GUID}"
# 真机(2026-10-15,账号 hy_297832664)getRedeemNeedGoods 响应:200 / 积分 280 / 门槛 20
REDEEM_RSP_HEX = "0a0100c8160ce69fa5e8afa2e68890e58a9f21011830144c50016001790c0b"
# 真机 getGrowLevelUserInfo 响应:成长值 980 / 成长等级 3 / 昵称 / 头像
GROW_RSP_HEX = (
"0a0a0c1606e68890e58a9f0b1a0103d41003230000019f124082043c4c50"
"0360ff7101f48103e80b260fe8bdafe8b88fe697a5e6bdbae6b9b236386874"
"7470733a2f2f6469792d6173736574732e6d737374617469632e636f6d2f6371"
"68666f6c6465722f4e657744656661756c742e706e670b"
)
class _FakeWs:
"""记录发出的帧,并立刻兑现 RPC future(离线跑完整链路)。"""
def __init__(self, client: HuyaWssClient):
self.client = client
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(b"")
def last_request(self) -> dict:
return _decode_wup_body(WssMessage.decode(self.sent[-1]).body)
def _points_client() -> HuyaWssClient:
client = HuyaWssClient(logger=lambda _message: None)
client._points_mode = True
client.ws = _FakeWs(client)
return client
def _raw_rsp(payload_hex: str) -> ShopRawRsp:
return ShopRawRsp.from_payload(
decode_struct_bytes(bytes.fromhex("".join(payload_hex.split())))
)
class _StubClient:
"""按需返回真机响应字节的积分中心客户端。"""
def __init__(self, redeem=True, grow=True):
self.redeem = _raw_rsp(REDEEM_RSP_HEX) if redeem else None
self.grow = _raw_rsp(GROW_RSP_HEX) if grow else None
self.calls: list[tuple[str, dict]] = []
def get_redeem_need_goods(self, **kwargs):
self.calls.append(("get_redeem_need_goods", kwargs))
return self.redeem
def get_grow_level_user_info(self, **kwargs):
self.calls.append(("get_grow_level_user_info", kwargs))
return self.grow
class _LegacyClient:
"""旧活动接口:真机实测任何 sid 都返回 603「活动已结束」。"""
def query_user_score(self, uid: int, cookie: str, sid: int):
response = GetUserScoreResp()
response.status = 603
response.msg = "活动已结束"
response.score = 0
return response
def _runner(monkeypatch, client, legacy=None) -> tuple[HuyaBatchRunner, list]:
runner = HuyaBatchRunner(db=None, batch_id="test-batch", task_type="query_points")
marks: list[tuple[str, str, dict | None]] = []
monkeypatch.setattr(
runner, "_shop_client", lambda uid, cookie: client, raising=False
)
if legacy is not None:
monkeypatch.setattr(
runner, "_activity_client", lambda uid, cookie: legacy, raising=False
)
monkeypatch.setattr(
runner,
"_mark_task",
lambda _db, _task, status, message, result=None: marks.append(
(status, message, result)
),
raising=False,
)
return runner, marks
def test_wss_redeem_need_goods_request_matches_real_call():
async def run():
client = _points_client()
await client.get_redeem_need_goods(uid=UID, guid=GUID, cookie=COOKIE)
message = WssMessage.decode(client.ws.sent[-1])
# 积分中心业务帧统一 0x1d000107(只有下单/支付用 0x1d000108)
assert message.sequence == 0x1D000107
decoded = client.ws.last_request()
assert (decoded["servant"], decoded["func"]) == (
"shopMiddleUI",
"getRedeemNeedGoods",
)
t_req = decoded["tReq"]
assert t_req["tag0"]["tag0"] == UID
assert t_req["tag1"]["tag0"] == "huya"
assert t_req["tag1"]["tag4"] == 7
assert t_req["tag1"]["tag5"] == "yellowcarlist"
# 默认兑换商品号必须是真机可用的 hy-8315982 的数字形式
assert t_req["tag2"] == 8315982
asyncio.run(run())
def test_wss_grow_level_request_carries_only_user_id():
async def run():
client = _points_client()
await client.get_grow_level_user_info(uid=UID, guid=GUID, cookie=COOKIE)
decoded = client.ws.last_request()
assert (decoded["servant"], decoded["func"]) == (
"GameBizUI",
"getGrowLevelUserInfo",
)
t_req = decoded["tReq"]
assert t_req["tag0"]["tag0"] == UID
# 抓包 tReq 只有 tag0,没有 shopAppInfo
assert set(t_req) == {"tag0"}
asyncio.run(run())
def test_http_redeem_need_goods_request_matches_real_call():
client = HuyaHttpClient(logger=lambda _message: None)
recorded: list[tuple] = []
def fake_call_rpc(
service, method, req_struct, rsp_class=None, uid=0, guid="", cookie="", **kwargs
):
recorded.append((service, method, req_struct))
client.call_rpc = fake_call_rpc # type: ignore[method-assign]
client.get_redeem_need_goods(uid=UID, guid="", cookie=COOKIE)
service, method, req = recorded[-1]
assert (service, method) == ("shopMiddleUI", "getRedeemNeedGoods")
assert req.userId.lUid == UID
assert req.shopAppInfo.scene == 7
assert req.shopAppInfo.sourceId == "yellowcarlist"
assert req.goodsNum == 8315982
def test_real_redeem_response_bytes_are_points_and_threshold():
rsp = _raw_rsp(REDEEM_RSP_HEX)
assert rsp.code == 200
assert rsp.message == "查询成功"
# tag2 = 当前积分,tag3 = 兑换门槛积分(真机 280 / 20)
assert rsp.value("tag2") == 280
assert rsp.value("tag3") == 20
def test_real_grow_level_response_bytes_are_level_and_nickname():
rsp = _raw_rsp(GROW_RSP_HEX)
assert rsp.code == 0
assert rsp.message == "成功"
assert rsp.value("tag1", "tag0") == 980 # 成长值
assert rsp.value("tag1", "tag1") == 3 # 成长等级
assert rsp.value("tag2") == "软踏日潺湲"
assert str(rsp.value("tag3")).startswith("https://diy-assets.msstatic.com/")
def test_query_points_reads_points_from_points_center(monkeypatch):
client = _StubClient()
runner, marks = _runner(monkeypatch, client)
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
runner._execute_query_points(
None, None, account, {"uid": UID, "cookie": COOKIE}, {}
)
assert [name for name, _kwargs in client.calls] == [
"get_redeem_need_goods",
"get_grow_level_user_info",
]
assert client.calls[0][1]["goods_num"] == 8315982
status, message, result = marks[-1]
assert status == "success"
assert message == "积分: 280"
assert account.points == 280
assert result["points"] == 280
assert result["redeem_threshold"] == 20
assert result["grow_level"] == 3
assert result["grow_value"] == 980
assert result["nickname"] == "软踏日潺湲"
def test_query_points_keeps_working_when_grow_level_fails(monkeypatch):
client = _StubClient(grow=False)
runner, marks = _runner(monkeypatch, client)
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
runner._execute_query_points(
None, None, account, {"uid": UID, "cookie": COOKIE}, {}
)
status, message, result = marks[-1]
assert status == "success"
assert message == "积分: 280"
assert "grow_level" not in result
def test_query_points_reports_server_error_of_points_center(monkeypatch):
class _FailingClient(_StubClient):
def get_redeem_need_goods(self, **kwargs):
self.calls.append(("get_redeem_need_goods", kwargs))
return ShopRawRsp.from_payload(
decode_struct_bytes(
bytes.fromhex(
"0a0101f4161ee7b3bbe7bb9fe5bc82e5b8b8efbc8ce8afb7e7a88de5"
"908ee9878de8af952c3c4c5c6c0b"
)
)
)
client = _FailingClient()
runner, marks = _runner(monkeypatch, client)
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
runner._execute_query_points(
None, None, account, {"uid": UID, "cookie": COOKIE}, {}
)
status, message, result = marks[-1]
assert status == "failed"
assert message == "系统异常,请稍后重试"
assert result["code"] == 500
assert result["goods_num"] == 8315982
def test_query_points_fallback_reports_legacy_interface_offline(monkeypatch):
client = _StubClient(redeem=False)
runner, marks = _runner(monkeypatch, client, legacy=_LegacyClient())
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
runner._execute_query_points(
None, None, account, {"uid": UID, "cookie": COOKIE}, {"sid": "2203"}
)
status, message, _result = marks[-1]
assert status == "failed"
# 旧接口的真实返回必须如实暴露,而不是只报「活动已结束」
assert "旧活动接口(sid=2203)返回 603 活动已结束" in message
assert "积分中心接口无响应" in message
def test_query_points_without_sid_reports_points_center_offline(monkeypatch):
client = _StubClient(redeem=False)
runner, marks = _runner(monkeypatch, client, legacy=_LegacyClient())
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
runner._execute_query_points(
None, None, account, {"uid": UID, "cookie": COOKIE}, {}
)
status, message, _result = marks[-1]
assert status == "error"
assert "积分中心接口无响应" in message