From 7ca0f7ee28301366c7a402733dec7be305cf6073 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 15 Oct 2026 15:57:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(huya):=20=E5=88=B7=E6=96=B0=E5=BC=80?= =?UTF-8?q?=E9=80=9A=E5=95=86=E5=93=81=E4=B8=8D=E5=86=8D=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=B7=B2=E4=B8=8B=E7=BA=BF=E7=9A=84=E6=B4=BB=E5=8A=A8=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机(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 个商品,价格/库存与网页一致 --- tests/test_huya_recharge_goods_source.py | 286 +++++++++++++++++++ web/backend/routers/huya/workbench.py | 13 +- web/backend/services/huya_runner_recharge.py | 143 +++++++--- web/frontend/src/pages/HuyaElitePage.tsx | 11 +- web/frontend/src/pages/HuyaTasksPage.tsx | 2 +- 5 files changed, 405 insertions(+), 50 deletions(-) create mode 100644 tests/test_huya_recharge_goods_source.py diff --git a/tests/test_huya_recharge_goods_source.py b/tests/test_huya_recharge_goods_source.py new file mode 100644 index 0000000..98c2a90 --- /dev/null +++ b/tests/test_huya_recharge_goods_source.py @@ -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" diff --git a/web/backend/routers/huya/workbench.py b/web/backend/routers/huya/workbench.py index 891ef4a..6281bae 100644 --- a/web/backend/routers/huya/workbench.py +++ b/web/backend/routers/huya/workbench.py @@ -132,15 +132,22 @@ def list_recharge_goods( db: Session = Depends(get_db), current: User = Depends(require_permission("huya:task")), ): - """查看已缓存的虎牙充值商品快照。""" + """查看已缓存的虎牙充值商品快照。 + + 只返回账号级快照(account_id 非空):库里还有一批 2026-07-04 写入的全局默认行 + (account_id 为空、sid=0,含旧版精英宝典/精英令/精英徽章),它们已不是当前在售 + 商品,未刷新过的账号显示空列表、点一次「刷新开通商品」即可。同一 spu_id 只保留 + 最新一行,避免下拉框出现同名重复项。 + """ rows = ( db.query(HuyaRechargeGoodsSnapshot) + .filter(HuyaRechargeGoodsSnapshot.account_id.is_not(None)) .order_by(HuyaRechargeGoodsSnapshot.id.desc()) .all() ) - latest = {} + latest: dict[str, HuyaRechargeGoodsSnapshot] = {} for row in rows: - latest.setdefault((row.sid, row.spu_id), row) + latest.setdefault(row.spu_id, row) return list(latest.values()) diff --git a/web/backend/services/huya_runner_recharge.py b/web/backend/services/huya_runner_recharge.py index 0719534..4c1fc9f 100644 --- a/web/backend/services/huya_runner_recharge.py +++ b/web/backend/services/huya_runner_recharge.py @@ -19,14 +19,58 @@ HUYA_RECHARGE_ACT_ID = 25135 HUYA_RECHARGE_APP_SCENE = SHOP_APP_SCENE HUYA_PAYMENT_POLL_SECONDS = 180 HUYA_PAYMENT_POLL_INTERVAL = 3 -HUYA_RECHARGE_EXTRA_PRODUCTS = [ +# 内置开通商品候选 = 积分中心页面(m-shop.huya.com/points.html)「赠送指定礼物得积分」 +# 那一排商品,全部经 shopMiddleUI.getGoodsInfoV5 真机验证 code=200: +# hy-8328015 精英宝典 ¥30(送300积分,2026-10-13 抓包下单商品) +# hy-8327356 精英币 ¥0.1(已开通后首格「自定义数量」,送1积分) +# hy-5872824 精英徽章 ¥1(送10积分)、hy-5879346 精英令 ¥10(送100积分) +# hy-5874269 精英令*10 ¥100(送1000积分)、hy-5874270 精英令*100 ¥1000(送10000积分) +# 旧版精英宝典 hy-5879340 页面上已不再使用,从候选里去掉。 +# 旧活动接口 webActUI.getActTaskDetail(act 25135) 已下线(601 活动已下线), +# 商品来源不能再依赖它,只能作为可选补充。 +HUYA_RECHARGE_BUILTIN_PRODUCTS = [ { - "spu_id": "hy-5879340", + "spu_id": "hy-8328015", "name": "精英宝典", "task_name": "开通精英宝典", "description": "得300积分丨解锁道具兑换权益", "sort": 0, }, + { + "spu_id": "hy-8327356", + "name": "精英币", + "task_name": "购买精英币", + "description": "得1积分丨可兑指定返场皮肤道具", + "sort": 1, + }, + { + "spu_id": "hy-5872824", + "name": "精英徽章", + "task_name": "购买精英徽章", + "description": "送10积分", + "sort": 2, + }, + { + "spu_id": "hy-5879346", + "name": "精英令", + "task_name": "购买精英令", + "description": "送100积分", + "sort": 3, + }, + { + "spu_id": "hy-5874269", + "name": "精英令*10", + "task_name": "购买精英令*10", + "description": "送1000积分", + "sort": 4, + }, + { + "spu_id": "hy-5874270", + "name": "精英令*100", + "task_name": "购买精英令*100", + "description": "送10000积分", + "sort": 5, + }, ] from .huya_runner_core import HUYA_RECHARGE_SOURCE_ID @@ -226,23 +270,6 @@ class RechargeMixin: return client: Any = self._shop_client(uid, cookie) - task_resp = client.get_act_task_detail( - uid=uid, cookie=cookie, act_id=HUYA_RECHARGE_ACT_ID - ) - if task_resp is None: - self._mark_task(worker_db, task, "error", "虎牙充值任务详情接口无响应") - return - - task_result = task_resp.to_dict() - if task_resp.status != 200: - self._mark_task( - worker_db, - task, - "failed", - task_resp.msg or f"虎牙充值任务详情获取失败: {task_resp.status}", - task_result, - ) - return candidates: list[dict] = [] seen: set[str] = set() @@ -254,29 +281,51 @@ class RechargeMixin: seen.add(spu_id) candidates.append(item) - for item in HUYA_RECHARGE_EXTRA_PRODUCTS: + for item in HUYA_RECHARGE_BUILTIN_PRODUCTS: add_candidate(dict(item)) - for index, item in enumerate(task_result.get("tasks", []), start=1): - if int(item.get("task_type") or 0) != 67: - continue - add_candidate( - { - "spu_id": item.get("spu_id") or "", - "name": item.get("name") or "", - "task_id": str(item.get("task_id") or ""), - "task_name": item.get("name") or "", - "description": item.get("description") or "", - "icon": item.get("icon") or "", - "task_url": item.get("task_url") or "", - "prizes": item.get("prizes") or [], - "sort": index, - } + # 旧活动任务接口(webActUI.getActTaskDetail,act 25135)已下线 + # (真机 2026-10-15:601 活动已下线),只作为可选补充: + # 能返回就并入活动里的商品,失败不再阻断整次刷新。 + task_result: dict | None = None + task_error = "" + task_resp = client.get_act_task_detail( + uid=uid, cookie=cookie, act_id=HUYA_RECHARGE_ACT_ID + ) + if task_resp is None: + task_error = "活动任务详情接口无响应" + else: + task_result = task_resp.to_dict() + if task_resp.status == 200: + for index, item in enumerate(task_result.get("tasks", []), start=1): + if int(item.get("task_type") or 0) != 67: + continue + add_candidate( + { + "spu_id": item.get("spu_id") or "", + "name": item.get("name") or "", + "task_id": str(item.get("task_id") or ""), + "task_name": item.get("name") or "", + "description": item.get("description") or "", + "icon": item.get("icon") or "", + "task_url": item.get("task_url") or "", + "prizes": item.get("prizes") or [], + "sort": 1000 + index, + } + ) + else: + task_error = ( + task_resp.msg or f"活动任务详情获取失败: {task_resp.status}" + ) + if task_error: + self._push_log( + "warning", + f"[{uid}] 活动任务接口不可用({task_error}),按内置开通商品刷新", ) if not candidates: self._mark_task( - worker_db, task, "failed", "未从活动任务中发现充值商品", task_result + worker_db, task, "failed", "未发现可用的开通商品", task_result or {} ) return @@ -328,10 +377,10 @@ class RechargeMixin: } goods.append(item) + # 清掉该账号的全部旧商品行(不限 sid):否则跨 sid 的旧行会和本次结果 + # 在列表接口里叠成同一商品的重复项(如 精英宝典 x2)。 worker_db.query(HuyaRechargeGoodsSnapshot).filter( HuyaRechargeGoodsSnapshot.account_id == account.id, - HuyaRechargeGoodsSnapshot.sid - == (self._to_int(config_info.get("sid")) or 2203), ).delete(synchronize_session=False) for item in goods: worker_db.add( @@ -355,9 +404,6 @@ class RechargeMixin: account.status = "recharge_goods_refreshed" account.updated_at = now - message = f"已刷新充值商品 {len(goods)} 个" - if failed: - message += f",失败 {len(failed)} 个" result = { "act_id": HUYA_RECHARGE_ACT_ID, "goods_count": len(goods), @@ -365,10 +411,19 @@ class RechargeMixin: "goods": goods, "failed": failed, "task_detail": task_result, + "task_detail_error": task_error, } - self._mark_task( - worker_db, task, "success" if goods else "failed", message, result - ) + if not goods: + reason = failed[0].get("message") if failed else "无候选商品" + self._mark_task( + worker_db, task, "failed", f"开通商品刷新失败: {reason}", result + ) + return + + message = f"已刷新开通商品 {len(goods)} 个" + if failed: + message += f",失败 {len(failed)} 个" + self._mark_task(worker_db, task, "success", message, result) def _execute_create_recharge_order( self, diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index 3a15b19..0e76906 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -78,7 +78,7 @@ export default function HuyaElitePage() { const [goods, setGoods] = useState([]); const [selectedGoodsId, setSelectedGoodsId] = useState(''); const [rechargeGoods, setRechargeGoods] = useState([]); - const [selectedRechargeSpu, setSelectedRechargeSpu] = useState('hy-5879340'); + const [selectedRechargeSpu, setSelectedRechargeSpu] = useState(''); const [search, setSearch] = useState(''); const [loading, setLoading] = useState(false); const [starting, setStarting] = useState(false); @@ -240,7 +240,14 @@ export default function HuyaElitePage() { return accounts.filter((item) => !value || [item.uid, item.nickname, item.username, item.tag, item.game_name].some((v) => String(v || '').toLowerCase().includes(value))); }, [accounts, search]); const filteredGoods = useMemo(() => goods.map((item) => ({ ...item, label: `${item.name} / ${item.price ?? '-'}分 / ${item.remain_text || '动态库存'}` })), [goods]); - const filteredRechargeGoods = useMemo(() => rechargeGoods.map((item) => ({ ...item, label: `${item.name || item.spu_id} / ${item.price ? `${(item.price / 100).toFixed(2)}元` : '价格动态'}` })), [rechargeGoods]); + const filteredRechargeGoods = useMemo(() => rechargeGoods.map((item) => ({ ...item, label: `${item.name || item.spu_id} / ${item.price ? `${(item.price / 100).toFixed(2)}元` : '价格动态'} / ${item.spu_id}` })).sort((a, b) => Number(a.raw?.raw_order ?? 0) - Number(b.raw?.raw_order ?? 0) || a.id - b.id), [rechargeGoods]); + // 默认选中列表里的第一个商品;已选商品从列表消失时同步回落到第一个 + useEffect(() => { + if (!filteredRechargeGoods.length) return; + if (!filteredRechargeGoods.some((item) => item.spu_id === selectedRechargeSpu)) { + setSelectedRechargeSpu(filteredRechargeGoods[0].spu_id); + } + }, [filteredRechargeGoods, selectedRechargeSpu]); const exchangeCount = tasks.filter((task) => task.task_type === 'exchange_goods').length; const accountColumns: TableProps['columns'] = [ diff --git a/web/frontend/src/pages/HuyaTasksPage.tsx b/web/frontend/src/pages/HuyaTasksPage.tsx index 242baef..f01dd2f 100644 --- a/web/frontend/src/pages/HuyaTasksPage.tsx +++ b/web/frontend/src/pages/HuyaTasksPage.tsx @@ -676,7 +676,7 @@ export default function HuyaTasksPage() { const rechargeGoodsOptions = useMemo(() => { return sortedRechargeGoods.map((item) => ({ value: item.spu_id, - label: `${item.name || item.spu_id}${item.price ? ` / ${formatPriceText(item.price)}` : ''}`, + label: `${item.name || item.spu_id}${item.price ? ` / ${formatPriceText(item.price)}` : ''} / ${item.spu_id}`, })); }, [sortedRechargeGoods]);