From 9a0d59825cd10c02ca6fbbe4986016d809603bb7 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 15 Oct 2026 21:01:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(huya):=20=E5=BC=80=E9=80=9A=E5=95=86?= =?UTF-8?q?=E5=93=81=E5=8F=AA=E4=BF=9D=E7=95=99=E7=9C=9F=E6=AD=A3=E5=8F=AF?= =?UTF-8?q?=E8=B4=AD=E4=B9=B0=E7=9A=84=20spu=EF=BC=8C=E5=B9=B6=E6=B8=85?= =?UTF-8?q?=E7=90=86=E5=8E=86=E5=8F=B2=E6=AD=BB=E5=95=86=E5=93=81=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机 2026-10-15(HAR 10.15-精英徽章两个-1 + 逐个 spu 探测):面板里的「精英徽章 hy-5872824」是积分中心「赠送指定礼物得积分」那排的礼物 spu —— getGoodsInfoV5 仍返回 200+价格,但 listPayChannelV5 返回 401「您的设备环境暂不支持购买该商品」,所以下单必报「当前支付渠道不可用」(与数量无关)。真能买的是 hy-8343549 精英徽章 ¥1 / hy-8343551 精英令 ¥10。 - 内置候选换成可购买的 4 个:hy-8328015 宝典 / hy-8327356 币 / hy-8343549 徽章 / hy-8343551 令 - 刷新商品增加「能买到」闸门:详情 200 后再校验 listPayChannelV5,非 200 或渠道为空即剔除并记录服务端原话 - 下单流程渠道失败时透出服务端消息,只有渠道真不在可用列表才说「当前支付渠道不可用(可用:…)」 - 商品列表只认「最近一次刷新」那一套:原先按账号并集返回,别的账号旧刷新留下的礼物 spu 会和最新一套叠成 7 个选项 - 刷新时清理历史死行:本账号旧行 + 全局默认行(account_id 为空)+ 其它账号里已不在候选中的 spu 行 - 测试:401 商品被剔除、渠道失败透传、列表只认最近一套、刷新清理别处死行 --- tests/test_huya_recharge_goods_source.py | 188 ++++++++++++++++++- tests/test_huya_shop_flow_reuse.py | 44 ++++- web/backend/routers/huya/workbench.py | 20 +- web/backend/services/huya_runner_recharge.py | 86 ++++++--- web/backend/services/huya_shop_flow.py | 24 ++- 5 files changed, 315 insertions(+), 47 deletions(-) diff --git a/tests/test_huya_recharge_goods_source.py b/tests/test_huya_recharge_goods_source.py index 98c2a90..ee7bfd7 100644 --- a/tests/test_huya_recharge_goods_source.py +++ b/tests/test_huya_recharge_goods_source.py @@ -8,6 +8,9 @@ 补充:它失败只记 warning,不再让整次刷新失败。 """ +from datetime import UTC, datetime +from types import SimpleNamespace + from core.huya.activity_structs import GetActTaskDetailResp from core.huya.frame_decoder import decode_struct_bytes from core.huya.taf_protocol import TafInputStream, TafType @@ -19,14 +22,12 @@ CONFIG = {"room_pid": str(ROOM_PID)} # 真机 webActUI.getActTaskDetail(act 25135) 响应字节(status=601 / msg=活动已下线) ACT_TASK_OFFLINE_HEX = "0a010259160fe6b4bbe58aa8e5b7b2e4b88be7babf0b" -# 积分中心页面上的开通商品(getGoodsInfoV5 真机验证 code=200) +# 开通商品候选:必须 getGoodsInfoV5 与 listPayChannelV5 都 200(2026-10-15 真机核对) BUILTIN_SPU_LITERALS = [ "hy-8328015", "hy-8327356", - "hy-5872824", - "hy-5879346", - "hy-5874269", - "hy-5874270", + "hy-8343549", + "hy-8343551", ] @@ -65,10 +66,18 @@ class _FakeGoodsInfo: class _StubShopClient: - def __init__(self, task: _FakeActTaskDetail | None, goods: bool = True): + def __init__( + self, + task: _FakeActTaskDetail | None, + goods: bool = True, + unbuyable: set[str] | None = None, + ): self.task = task self.goods = goods + #: 这些 spu 能看不能买(真机:积分中心那批礼物 spu,listPayChannelV5 → 401) + self.unbuyable = unbuyable or set() self.goods_calls: list[str] = [] + self.channel_calls: list[str] = [] def get_act_task_detail(self, uid: int, cookie: str, act_id: int): return self.task @@ -84,6 +93,17 @@ class _StubShopClient: price=3000, ) + def list_pay_channels(self, **kwargs): + spu_id = kwargs["spu_id"] + self.channel_calls.append(spu_id) + if spu_id in self.unbuyable: + return SimpleNamespace( + code=401, + message="您的设备环境暂不支持购买该商品", + channels=[], + ) + return SimpleNamespace(code=200, message="", channels=["Zfb", "Weixin"]) + def _runner(monkeypatch, client) -> tuple[HuyaBatchRunner, list, list]: runner = HuyaBatchRunner( @@ -140,8 +160,8 @@ def test_refresh_recharge_goods_survives_offline_activity(db, monkeypatch): status, message, result = marks[-1] assert status == "success" - assert message == "已刷新开通商品 6 个" - assert result["goods_count"] == 6 + assert message == f"已刷新开通商品 {len(BUILTIN_SPU_LITERALS)} 个" + assert result["goods_count"] == len(BUILTIN_SPU_LITERALS) assert result["task_detail_error"] == "活动已下线" assert client.goods_calls == BUILTIN_SPU_LITERALS assert [item["spu_id"] for item in result["goods"]] == BUILTIN_SPU_LITERALS @@ -157,6 +177,41 @@ def test_refresh_recharge_goods_survives_offline_activity(db, monkeypatch): assert all(row.sku_id for row in rows) +def test_refresh_recharge_goods_drops_products_that_cannot_be_bought(db, monkeypatch): + """能看不能买的商品(listPayChannelV5 401)不进商品列表,避免下单时才报渠道错误。 + + 真机 2026-10-15:hy-5872824「精英徽章」getGoodsInfoV5=200 + ¥1,但 + listPayChannelV5=401「您的设备环境暂不支持购买该商品」;真正能买的是 hy-8343549。 + """ + client = _StubShopClient( + _FakeActTaskDetail(601, "活动已下线"), unbuyable={"hy-8343549"} + ) + 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 [item["spu_id"] for item in result["goods"]] == [ + spu for spu in BUILTIN_SPU_LITERALS if spu != "hy-8343549" + ] + assert result["failed_count"] == 1 + assert result["failed"][0]["spu_id"] == "hy-8343549" + assert result["failed"][0]["message"] == "您的设备环境暂不支持购买该商品" + assert "失败 1 个" in message + rows = ( + db.query(HuyaRechargeGoodsSnapshot) + .filter(HuyaRechargeGoodsSnapshot.account_id == account.id) + .all() + ) + assert "hy-8343549" not in {row.spu_id for row in rows} + + def test_refresh_recharge_goods_still_merges_activity_candidates(db, monkeypatch): task = _FakeActTaskDetail( 200, @@ -240,6 +295,123 @@ def test_refresh_recharge_goods_drops_stale_rows_of_other_sids(db, monkeypatch): assert len({row.spu_id for row in rows}) == len(rows) +def test_refresh_recharge_goods_purges_dead_products_everywhere(db, monkeypatch): + """刷新顺手清掉别处的死商品行:别的账号遗留的旧礼物 spu + 7-04 的全局默认行。 + + 真机 2026-10-15:账号 65/66 还留着修复前的礼物 spu 行,和最新一套并集后面板里 + 出现 7 个选项,选到礼物 spu 下单就报「当前支付渠道不可用」。 + """ + client = _StubShopClient(_FakeActTaskDetail(601, "活动已下线")) + runner, marks, _logs = _runner(monkeypatch, client) + other = HuyaAccount(uid="1199665170009", cookie="c9") + account = HuyaAccount(uid="1199665170519", cookie="yyuid=1199665170519", tag="") + db.add_all([other, account]) + db.commit() + old_time = datetime(2026, 10, 15, 7, 55, 17, tzinfo=UTC) + db.add_all( + [ + # 别的账号遗留的礼物 spu(能看不能买)→ 必须清掉 + HuyaRechargeGoodsSnapshot( + account_id=other.id, + sid=2203, + spu_id="hy-5872824", + sku_id="dead", + name="精英徽章", + updated_at=old_time, + ), + # 别的账号里仍在候选内的商品 → 保留 + HuyaRechargeGoodsSnapshot( + account_id=other.id, + sid=2203, + spu_id="hy-8343549", + sku_id="keep", + name="精英徽章", + updated_at=old_time, + ), + # 2026-07-04 的全局默认行 → 清掉 + HuyaRechargeGoodsSnapshot( + account_id=None, + sid=0, + spu_id="hy-5879340", + sku_id="seed", + name="精英宝典", + updated_at=old_time, + ), + ] + ) + 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).all() + spus = {row.spu_id for row in rows} + assert "hy-5872824" not in spus # 别的账号的死商品被清 + assert "hy-5879340" not in spus # 全局默认行被清 + assert "hy-8343549" in spus # 候选内的商品行保留 + assert {row.spu_id for row in rows if row.account_id == account.id} == set( + BUILTIN_SPU_LITERALS + ) + + +def test_list_recharge_goods_returns_only_latest_refresh_set(db): + """列表只认「最近一次刷新」那一套:别的账号旧刷新留下的死商品不能并集进来。""" + from web.backend.routers.huya import list_recharge_goods + + old_acc = HuyaAccount(uid="1199665170001", cookie="c1") + new_acc = HuyaAccount(uid="1199665170002", cookie="c2") + db.add_all([old_acc, new_acc]) + db.commit() + old_time = datetime(2026, 10, 15, 7, 55, 17, tzinfo=UTC) + new_time = datetime(2026, 10, 15, 12, 52, 0, tzinfo=UTC) + db.add_all( + [ + HuyaRechargeGoodsSnapshot( + account_id=old_acc.id, + sid=2203, + spu_id="hy-5872824", + sku_id="old", + name="精英徽章", + updated_at=old_time, + ), + HuyaRechargeGoodsSnapshot( + account_id=old_acc.id, + sid=2203, + spu_id="hy-5879346", + sku_id="old", + name="精英令", + updated_at=old_time, + ), + HuyaRechargeGoodsSnapshot( + account_id=new_acc.id, + sid=2203, + spu_id="hy-8343549", + sku_id="new", + name="精英徽章", + updated_at=new_time, + ), + HuyaRechargeGoodsSnapshot( + account_id=new_acc.id, + sid=2203, + spu_id="hy-8343551", + sku_id="new", + name="精英令", + updated_at=new_time, + ), + ] + ) + db.commit() + + rows = list_recharge_goods(db=db, current=None) + + assert sorted(row.spu_id for row in rows) == [ + "hy-8343549", + "hy-8343551", + ] + + def test_list_recharge_goods_hides_global_seed_rows_and_dedupes(db): """列表只返回账号级快照:隐藏全局默认行,同一 spu 只留最新一行。""" from web.backend.routers.huya import list_recharge_goods diff --git a/tests/test_huya_shop_flow_reuse.py b/tests/test_huya_shop_flow_reuse.py index b20da24..6a003bc 100644 --- a/tests/test_huya_shop_flow_reuse.py +++ b/tests/test_huya_shop_flow_reuse.py @@ -70,12 +70,18 @@ class _Client: orders=None, auth_code: int = 200, auth_message: str = "", + channel_code: int = 200, + channel_message: str = "", + channels=("Zfb",), ): self.create_code = create_code self.create_message = create_message self.orders = orders or [] self.auth_code = auth_code self.auth_message = auth_message + self.channel_code = channel_code + self.channel_message = channel_message + self.channels = list(channels) self.pay_order_ids: list[int] = [] self.order_list_calls = 0 self.create_calls = 0 @@ -97,7 +103,9 @@ class _Client: return resp def list_pay_channels(self, **kwargs): - return SimpleNamespace(code=200, message="", channels=["Zfb"]) + return SimpleNamespace( + code=self.channel_code, message=self.channel_message, channels=self.channels + ) def check_hy_protocol(self, **kwargs): return SimpleNamespace(code=200, message="") @@ -245,6 +253,40 @@ def test_auth_no_response_fails_without_touching_orders(): assert client.order_list_calls == 0 +def test_unbuyable_goods_surfaces_channel_message(): + """能看不能买的商品(listPayChannelV5 401):把服务端原话透出来。 + + 真机 2026-10-15:hy-5872824「精英徽章」是积分中心的礼物 spu,getGoodsInfoV5 + 返回 200 + ¥1,但支付渠道接口返回 401「您的设备环境暂不支持购买该商品」。 + 以前一律报「当前支付渠道不可用」,看不出是商品不能买。 + """ + client = _Client( + create_code=200, + channel_code=401, + channel_message="您的设备环境暂不支持购买该商品", + channels=(), + ) + + outcome = _run(client) + + assert outcome.ok is False + assert outcome.message == "您的设备环境暂不支持购买该商品" + # 渠道校验没过就不该下单、更不该起支付 + assert client.create_calls == 0 + assert client.pay_order_ids == [] + + +def test_unavailable_pay_channel_lists_what_is_available(): + client = _Client(create_code=200, channels=("Weixin",)) + + outcome = _run(client) # 默认走 Zfb + + assert outcome.ok is False + assert "当前支付渠道不可用" in outcome.message + assert "Weixin" in outcome.message + assert client.create_calls == 0 + + def test_paid_order_is_not_reused(): client = _Client( create_code=703, create_message=BUY_LIMIT_MESSAGE, orders=[PAID_ELITE_ORDER] diff --git a/web/backend/routers/huya/workbench.py b/web/backend/routers/huya/workbench.py index 6281bae..f761081 100644 --- a/web/backend/routers/huya/workbench.py +++ b/web/backend/routers/huya/workbench.py @@ -134,19 +134,29 @@ def list_recharge_goods( ): """查看已缓存的虎牙充值商品快照。 - 只返回账号级快照(account_id 非空):库里还有一批 2026-07-04 写入的全局默认行 - (account_id 为空、sid=0,含旧版精英宝典/精英令/精英徽章),它们已不是当前在售 - 商品,未刷新过的账号显示空列表、点一次「刷新开通商品」即可。同一 spu_id 只保留 - 最新一行,避免下拉框出现同名重复项。 + 商品目录是**全局同一套**(每个账号刷出来的是同样的商品),但表里是按账号存的行, + 所以这里只返回「最近一次刷新」那一整套: + - 账号级旧刷新留下的行(真机 2026-10-15:账号 65/66 的旧礼物 spu)会和最新一套 + 并集成 7 个选项,面板里就能选到「能看不能买」的商品; + - 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()) + .order_by( + HuyaRechargeGoodsSnapshot.updated_at.desc(), + HuyaRechargeGoodsSnapshot.id.desc(), + ) .all() ) + if not rows: + return [] + newest = rows[0].updated_at latest: dict[str, HuyaRechargeGoodsSnapshot] = {} for row in rows: + if row.updated_at != newest: + continue 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 6332a5c..f5e5f5c 100644 --- a/web/backend/services/huya_runner_recharge.py +++ b/web/backend/services/huya_runner_recharge.py @@ -20,12 +20,22 @@ HUYA_RECHARGE_ACT_ID = 25135 HUYA_RECHARGE_APP_SCENE = SHOP_APP_SCENE HUYA_PAYMENT_POLL_SECONDS = 180 HUYA_PAYMENT_POLL_INTERVAL = 3 -# 内置开通商品候选 = 积分中心页面(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积分) +# 内置开通商品候选。**必须同时满足** getGoodsInfoV5=200(能看到)与 +# listPayChannelV5=200(能买到)——真机(2026-10-15,hy_300546644 / uid 1199667339402): +# +# 可购买(两个接口都 200,支付渠道 ["Zfb","Weixin"]): +# hy-8328015 精英宝典 ¥30(sku 7756811,2026-10-13 抓包下单商品) +# hy-8327356 精英币 ¥0.1(sku 7759407) +# hy-8343549 精英徽章 ¥1 (sku 7750721,10.15「精英徽章两个」抓包下单商品) +# hy-8343551 精英令 ¥10(sku 7750723) +# 不可购买(getGoodsInfoV5 仍返回 200 + 价格,但 listPayChannelV5 返回 +# 401「您的设备环境暂不支持购买该商品」)——这是积分中心「赠送指定礼物得积分」 +# 那一排的礼物 spu,只能送礼物拿积分,不能当商品下单: +# hy-5872824 精英徽章 ¥1、hy-5879346 精英令 ¥10、 +# hy-5874269 精英令*10 ¥100、hy-5874270 精英令*100 ¥1000 +# (这就是之前多选数量下单报「当前支付渠道不可用」的原因:面板里选的是礼物 spu。 +# 精英令*10/*100 暂未找到可购买的 spu,等抓到对应页面再补。) +# # 旧版精英宝典 hy-5879340 页面上已不再使用,从候选里去掉。 # 旧活动接口 webActUI.getActTaskDetail(act 25135) 已下线(601 活动已下线), # 商品来源不能再依赖它,只能作为可选补充。 @@ -45,33 +55,19 @@ HUYA_RECHARGE_BUILTIN_PRODUCTS = [ "sort": 1, }, { - "spu_id": "hy-5872824", + "spu_id": "hy-8343549", "name": "精英徽章", "task_name": "购买精英徽章", - "description": "送10积分", + "description": "得10积分丨可兑指定返场皮肤道具", "sort": 2, }, { - "spu_id": "hy-5879346", + "spu_id": "hy-8343551", "name": "精英令", "task_name": "购买精英令", - "description": "送100积分", + "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 @@ -374,6 +370,31 @@ class RechargeMixin: ) continue + # 商品详情 200 只代表「能看到」:积分中心那批礼物 spu(hy-5872824 等)在这里 + # 也能返回价格,但 listPayChannelV5 会返回 401 + # 「您的设备环境暂不支持购买该商品」,选它下单必然失败。用支付渠道当 + # 「能买到」的闸门,只留真正可下单的商品(真机 2026-10-15)。 + channels = client.list_pay_channels( + uid=uid, + guid="", + cookie=cookie, + spu_id=spu_id, + sku_id=0, + ) + if channels is None: + failed.append({"spu_id": spu_id, "message": "支付渠道接口无响应"}) + continue + if channels.code != 200 or not getattr(channels, "channels", None): + failed.append( + { + "spu_id": spu_id, + "message": channels.message + or f"该商品当前不可购买(支付渠道 {channels.code})", + "detail": detail, + } + ) + continue + item = { **candidate, **detail, @@ -390,11 +411,24 @@ class RechargeMixin: } goods.append(item) - # 清掉该账号的全部旧商品行(不限 sid):否则跨 sid 的旧行会和本次结果 - # 在列表接口里叠成同一商品的重复项(如 精英宝典 x2)。 + # 商品目录是全局同一套,顺手把历史遗留的「能看不能买」行也清掉: + # ① 本账号的全部旧商品行(不限 sid:跨 sid 的旧行会和本次结果叠成重复项); + # ② 其它账号里已经不在候选里的 spu(真机 2026-10-15:账号 65/66 还留着修复前的 + # 礼物 spu hy-5872824 等,会和最新一套并集成 7 个选项出现在面板里); + # ③ 2026-07-04 的全局默认行(account_id 为空,早已不是在售商品)。 + kept_spus = {item["spu_id"] for item in goods} worker_db.query(HuyaRechargeGoodsSnapshot).filter( HuyaRechargeGoodsSnapshot.account_id == account.id, ).delete(synchronize_session=False) + worker_db.query(HuyaRechargeGoodsSnapshot).filter( + HuyaRechargeGoodsSnapshot.account_id.is_(None), + ).delete(synchronize_session=False) + if kept_spus: + worker_db.query(HuyaRechargeGoodsSnapshot).filter( + HuyaRechargeGoodsSnapshot.account_id.is_not(None), + HuyaRechargeGoodsSnapshot.account_id != account.id, + HuyaRechargeGoodsSnapshot.spu_id.notin_(kept_spus), + ).delete(synchronize_session=False) for item in goods: worker_db.add( HuyaRechargeGoodsSnapshot( diff --git a/web/backend/services/huya_shop_flow.py b/web/backend/services/huya_shop_flow.py index 208c207..8552a30 100644 --- a/web/backend/services/huya_shop_flow.py +++ b/web/backend/services/huya_shop_flow.py @@ -168,17 +168,27 @@ def run_open_flow( emit(f"[{uid}] 小店:{supplier_name or '未知'}(supplierId={supplier_id})") # 3) 支付渠道 + # + # 真机 2026-10-15:积分中心那批「礼物」spu(hy-5872824 精英徽章、hy-5879346 + # 精英令…)在这里返回 401「您的设备环境暂不支持购买该商品」,而商品详情接口 + # 仍是 200 + 价格 —— 「详情拿得到」不代表「能买」。失败时把服务端原话透出来, + # 不要再糊一句「当前支付渠道不可用」让人以为是渠道配置问题。 channels = client.list_pay_channels( uid=uid, guid="", cookie=cookie, spu_id=spu_id, sku_id=0 ) - available_channels = set(getattr(channels, "channels", []) or []) - if ( - channels is None - or channels.code != 200 - or pay_channel not in available_channels - ): + if channels is None: + return fail("支付渠道接口无响应", detail=detail, supplier_name=supplier_name) + if channels.code != 200: return fail( - "当前支付渠道不可用", + channels.message or f"该商品当前不可购买(支付渠道 {channels.code})", + detail=detail, + supplier_name=supplier_name, + ) + available_channels = set(getattr(channels, "channels", []) or []) + if pay_channel not in available_channels: + channel_text = "/".join(sorted(available_channels)) or "无" + return fail( + f"当前支付渠道不可用(可用:{channel_text})", detail=detail, supplier_name=supplier_name, )