"""开通商品来源:不再依赖已下线的旧活动任务接口(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 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 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 与 listPayChannelV5 都 200(2026-10-15 真机核对) BUILTIN_SPU_LITERALS = [ "hy-8328015", "hy-8327356", "hy-8343549", "hy-8343551", ] 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, 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 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 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( 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 == 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 # 活动接口不可用只记 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_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, "请求成功", [ {"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_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 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"