真机 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 商品被剔除、渠道失败透传、列表只认最近一套、刷新清理别处死行
459 lines
16 KiB
Python
459 lines
16 KiB
Python
"""开通商品来源:不再依赖已下线的旧活动任务接口(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"
|