fix(huya): 刷新开通商品不再依赖已下线的活动任务接口

真机(2026-10-15)实测 webActUI.getActTaskDetail(act 25135) 返回
601「活动已下线」,刷新开通商品第一步就失败,下拉框一直为空。

- 商品来源改为内置候选(积分中心页面「赠送指定礼物得积分」那一排,
  全部经 getGoodsInfoV5 真机验证 code=200):精英宝典 hy-8328015 ¥30、
  精英币 hy-8327356 ¥0.1、精英徽章 hy-5872824 ¥1、精英令 hy-5879346 ¥10、
  精英令*10 hy-5874269 ¥100、精英令*100 hy-5874270 ¥1000;
  旧版精英宝典 hy-5879340 页面已不再使用,从候选移除
- 活动任务详情降级为可选补充:返回非 200 只记 warning 并写入
  result.task_detail_error,不再让整次刷新失败;失败原因以商品详情为准
- 刷新时清掉该账号全部旧商品行(不限 sid),避免同名商品重复
- 列表接口只返回账号级快照并过滤 2026-07-04 的全局种子行,
  同一 spu_id 保留最新一行(原先 精英宝典 会出现两次)
- 前端:下拉标签带 spu 便于区分,按内置顺序排序,默认自动选中第一项
- 新增 tests/test_huya_recharge_goods_source.py(6 条):活动下线响应字节
  回归、下线不阻断刷新、活动商品仍会并入、失败原因归属、跨 sid 清理、
  列表去重与隐藏种子行(均已做反向验证)

真机验证:刷新成功 6 个商品,价格/库存与网页一致
This commit is contained in:
yml2213
2026-10-15 15:57:25 +08:00
parent 905a7ef22c
commit 7ca0f7ee28
5 changed files with 405 additions and 50 deletions
+286
View File
@@ -0,0 +1,286 @@
"""开通商品来源:不再依赖已下线的旧活动任务接口(2026-10-15 真机实测)。
真机现状(账号 hy_297832664,HTTP cdnws 通道):
- ``webActUI.getActTaskDetail(act_id=25135)`` → 601「活动已下线」
- ``shopMiddleUI.getGoodsInfoV5`` 对 hy-8328015 / hy-8327356 / hy-5879340 均 code=200
因此刷新开通商品改为「内置商品候选 + getGoodsInfoV5 详情」,活动任务详情只做可选
补充:它失败只记 warning,不再让整次刷新失败。
"""
from core.huya.activity_structs import GetActTaskDetailResp
from core.huya.frame_decoder import decode_struct_bytes
from core.huya.taf_protocol import TafInputStream, TafType
from web.backend.models import HuyaAccount, HuyaRechargeGoodsSnapshot
from web.backend.services.huya_runner import HuyaBatchRunner
ROOM_PID = 1199650619883
CONFIG = {"room_pid": str(ROOM_PID)}
# 真机 webActUI.getActTaskDetail(act 25135) 响应字节(status=601 / msg=活动已下线)
ACT_TASK_OFFLINE_HEX = "0a010259160fe6b4bbe58aa8e5b7b2e4b88be7babf0b"
# 积分中心页面上的开通商品(getGoodsInfoV5 真机验证 code=200)
BUILTIN_SPU_LITERALS = [
"hy-8328015",
"hy-8327356",
"hy-5872824",
"hy-5879346",
"hy-5874269",
"hy-5874270",
]
class _FakeActTaskDetail:
def __init__(self, status: int, msg: str, tasks: list[dict] | None = None):
self.status = status
self.msg = msg
self._tasks = tasks or []
def to_dict(self) -> dict:
return {
"status": self.status,
"msg": self.msg,
"task_count": len(self._tasks),
"tasks": self._tasks,
}
class _FakeGoodsInfo:
def __init__(self, spu_id: str, sku_id: str, name: str, price: int):
self.code = 200
self.message = ""
self._data = {
"spu_id": spu_id,
"sku_id": sku_id,
"name": name,
"price": price,
"stock": 100,
"buy_limit": 1,
"icon": "",
"description": "",
}
def to_dict(self) -> dict:
return dict(self._data)
class _StubShopClient:
def __init__(self, task: _FakeActTaskDetail | None, goods: bool = True):
self.task = task
self.goods = goods
self.goods_calls: list[str] = []
def get_act_task_detail(self, uid: int, cookie: str, act_id: int):
return self.task
def get_goods_info(self, **kwargs):
self.goods_calls.append(kwargs["spu_id"])
if not self.goods:
return None
return _FakeGoodsInfo(
spu_id=kwargs["spu_id"],
sku_id=f"sku-{kwargs['spu_id']}",
name=f"name-{kwargs['spu_id']}",
price=3000,
)
def _runner(monkeypatch, client) -> tuple[HuyaBatchRunner, list, list]:
runner = HuyaBatchRunner(
db=None, batch_id="test-batch", task_type="refresh_recharge_goods"
)
marks: list[tuple[str, str, dict | None]] = []
logs: list[tuple[str, str]] = []
monkeypatch.setattr(
runner, "_shop_client", lambda uid, cookie: client, raising=False
)
monkeypatch.setattr(
runner,
"_mark_task",
lambda _db, _task, status, message, result=None: marks.append(
(status, message, result)
),
raising=False,
)
monkeypatch.setattr(
runner,
"_push_log",
lambda level, message: logs.append((level, message)),
raising=False,
)
return runner, marks, logs
def test_real_offline_response_bytes_report_activity_offline():
payload = decode_struct_bytes(bytes.fromhex(ACT_TASK_OFFLINE_HEX))
assert payload["tag0"] == 601
assert payload["tag1"] == "活动已下线"
# 结构体解析路径:tRsp 以 STRUCT_BEGIN 包一层(同 WupResponse.readStruct)
ins = TafInputStream(bytes.fromhex(ACT_TASK_OFFLINE_HEX))
_tag, dtype = ins.peek_head()
assert dtype == TafType.STRUCT_BEGIN
ins.read_head()
parsed = GetActTaskDetailResp()
parsed.read_from(ins)
assert (parsed.status, parsed.msg) == (601, "活动已下线")
def test_refresh_recharge_goods_survives_offline_activity(db, monkeypatch):
client = _StubShopClient(_FakeActTaskDetail(601, "活动已下线"))
runner, marks, logs = _runner(monkeypatch, client)
account = HuyaAccount(uid="1199665170519", cookie="yyuid=1199665170519", tag="")
db.add(account)
db.commit()
runner._execute_refresh_recharge_goods(
db, None, account, {"uid": 1199665170519, "cookie": account.cookie}, CONFIG
)
status, message, result = marks[-1]
assert status == "success"
assert message == "已刷新开通商品 6 个"
assert result["goods_count"] == 6
assert result["task_detail_error"] == "活动已下线"
assert client.goods_calls == BUILTIN_SPU_LITERALS
assert [item["spu_id"] for item in result["goods"]] == BUILTIN_SPU_LITERALS
# 活动接口不可用只记 warning,不把任务标失败
assert logs and logs[-1][0] == "warning" and "活动已下线" in logs[-1][1]
rows = (
db.query(HuyaRechargeGoodsSnapshot)
.filter(HuyaRechargeGoodsSnapshot.account_id == account.id)
.all()
)
assert sorted(row.spu_id for row in rows) == sorted(BUILTIN_SPU_LITERALS)
assert all(row.sku_id for row in rows)
def test_refresh_recharge_goods_still_merges_activity_candidates(db, monkeypatch):
task = _FakeActTaskDetail(
200,
"请求成功",
[
{"task_type": 67, "spu_id": "hy-7770001", "name": "活动商品", "task_id": 5},
{"task_type": 66, "spu_id": "hy-ignore-me", "name": "非充值任务"},
],
)
client = _StubShopClient(task)
runner, marks, logs = _runner(monkeypatch, client)
account = HuyaAccount(uid="1199665170519", cookie="yyuid=1199665170519", tag="")
db.add(account)
db.commit()
runner._execute_refresh_recharge_goods(
db, None, account, {"uid": 1199665170519, "cookie": account.cookie}, CONFIG
)
status, _message, result = marks[-1]
assert status == "success"
assert result["task_detail_error"] == ""
assert "hy-7770001" in client.goods_calls
assert "hy-ignore-me" not in client.goods_calls
assert result["goods_count"] == len(BUILTIN_SPU_LITERALS) + 1
assert logs == []
def test_refresh_recharge_goods_reports_goods_failure_not_activity_status(
db, monkeypatch
):
client = _StubShopClient(_FakeActTaskDetail(601, "活动已下线"), goods=False)
runner, marks, _logs = _runner(monkeypatch, client)
account = HuyaAccount(uid="1199665170519", cookie="yyuid=1199665170519", tag="")
db.add(account)
db.commit()
runner._execute_refresh_recharge_goods(
db, None, account, {"uid": 1199665170519, "cookie": account.cookie}, CONFIG
)
status, message, result = marks[-1]
assert status == "failed"
assert message == "开通商品刷新失败: 商品详情接口无响应"
# 失败原因必须来自商品详情,而不是把旧活动接口的「活动已下线」当结论
assert "活动已下线" not in message
assert result["failed_count"] == len(BUILTIN_SPU_LITERALS)
def test_refresh_recharge_goods_drops_stale_rows_of_other_sids(db, monkeypatch):
"""同账号跨 sid 的旧行必须清掉,否则列表会出现同名重复商品。"""
client = _StubShopClient(_FakeActTaskDetail(601, "活动已下线"))
runner, marks, _logs = _runner(monkeypatch, client)
account = HuyaAccount(uid="1199665170519", cookie="yyuid=1199665170519", tag="")
db.add(account)
db.commit()
# 上次刷新用的是另一个 sid(真实库里就存在 sid=0 的旧行)
db.add(
HuyaRechargeGoodsSnapshot(
account_id=account.id,
sid=0,
spu_id="hy-5879340",
sku_id="old-sku",
name="精英宝典",
)
)
db.commit()
runner._execute_refresh_recharge_goods(
db, None, account, {"uid": 1199665170519, "cookie": account.cookie}, CONFIG
)
assert marks[-1][0] == "success"
rows = (
db.query(HuyaRechargeGoodsSnapshot)
.filter(HuyaRechargeGoodsSnapshot.account_id == account.id)
.all()
)
assert len(rows) == len(BUILTIN_SPU_LITERALS)
assert {row.sid for row in rows} == {2203}
assert len({row.spu_id for row in rows}) == len(rows)
def test_list_recharge_goods_hides_global_seed_rows_and_dedupes(db):
"""列表只返回账号级快照:隐藏全局默认行,同一 spu 只留最新一行。"""
from web.backend.routers.huya import list_recharge_goods
account = HuyaAccount(uid="1199665170519", cookie="yyuid=1199665170519", tag="")
db.add(account)
db.commit()
# 历史全局默认行(2026-07-04 种子,含旧版精英宝典):不属于当前在售商品
db.add(
HuyaRechargeGoodsSnapshot(
account_id=None,
sid=0,
spu_id="hy-5879340",
sku_id="seed-sku",
name="精英宝典",
)
)
db.commit()
# 同一 spu 的两次账号刷新结果:只保留最新一行
db.add(
HuyaRechargeGoodsSnapshot(
account_id=account.id,
sid=2203,
spu_id="hy-8328015",
sku_id="stale-sku",
name="精英宝典",
)
)
db.commit()
db.add(
HuyaRechargeGoodsSnapshot(
account_id=account.id,
sid=2203,
spu_id="hy-8328015",
sku_id="fresh-sku",
name="精英宝典",
)
)
db.commit()
rows = list_recharge_goods(db=db, current=None)
assert [row.spu_id for row in rows] == ["hy-8328015"]
assert rows[0].sku_id == "fresh-sku"