Files
live-hub-py/web/backend/services/huya_shop_flow.py
T
yml2213 9a0d59825c fix(huya): 开通商品只保留真正可购买的 spu,并清理历史死商品行
真机 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 商品被剔除、渠道失败透传、列表只认最近一套、刷新清理别处死行
2026-10-15 21:01:23 +08:00

346 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""虎牙积分中心(m-shop.huya.com/points.html)开通 / 买币下单链路。
2026-10-13「开通精英宝典 / 购买幸运币」抓包实证的帧顺序:
getGoodsInfoV5 → getSupplierInfoV5 → listPayChannelV5 → checkHyProtocolV5
→ PlayMallUI.getMyPromotion → calcOrderPromotion → acceptHyProtocolV5
→ checkUserBuyAuth → createOrderV5 → payOrderSubmitV5 → orderDetailV5(轮询)
本模块只负责「准备 → 下单 → 起支付」;支付到账轮询由调用方决定(任务侧会轮询,
外部 CK 批量开通只出支付码)。任务执行器与外部 CK 批量开通共用这里,避免链路
在两处各写一遍。
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
#: 小黄车供应商(和平精英官方小店),抓包实测。
SUPPLIER_ID_YELLOW_CAR = 87401
#: 订单列表里「已支付/已交付」的状态值(与任务侧 _is_huya_order_paid 对齐)。
PAID_ORDER_STATUSES = frozenset({50, 55, 60, 70, 80})
#: 只有这两种状态能被继续支付:10=待支付、1000=尾款待支付
#: (core.huya.shop_structs.OrderStatus;90=已取消、60=已关闭都不能复用,
#: 真机 2026-10-15:账号 66 的旧单被用户取消后 order_status 变成 90)
REUSABLE_ORDER_STATUSES = frozenset({10, 1000})
#: 复用旧单时最多翻几页订单。
REUSE_ORDER_PAGE_SIZE = 20
REUSE_ORDER_MAX_PAGES = 3
@dataclass
class ShopOrderOutcome:
"""开通链路的结果(ok=False 时 message 是失败原因)。"""
ok: bool
message: str
detail: dict = field(default_factory=dict)
supplier_name: str = ""
promotion: dict | None = None
promotion_info: dict | None = None
order_raw: dict = field(default_factory=dict)
pay_raw: dict = field(default_factory=dict)
order_id: int = 0
app_order_id: str = ""
pay_order_id: str = ""
pay_url: str = ""
amount: int = 0
pay_channel: str = "Zfb"
sku_id: int = 0
unit_price: int = 0
count: int = 1
product_name: str = ""
#: 复用了未支付的旧订单时为那张单的原始信息(限购商品没付掉就下不了新单)
reused_order: dict = field(default_factory=dict)
def find_reusable_order(
client: Any,
*,
uid: int,
cookie: str,
spu_id: str,
page_size: int = REUSE_ORDER_PAGE_SIZE,
max_pages: int = REUSE_ORDER_MAX_PAGES,
) -> dict:
"""找这个账号**同一商品**的未支付订单(用于复用旧单继续支付)。
真机(2026-10-15):已经有一张未支付的精英宝典订单时,**限购校验**
`checkUserBuyAuth` 会先返回 703
「已超过该商品的购买上限,下单失败~(如有未支付订单,请及时完成支付)」,
`createOrderV5` 根本不会执行(下单接口有时也会返回同一句)。限购 1 件的
商品旧单没付掉就再也下不了新单,这时应该拿旧单去发起支付,而不是把任务
判失败(官方页面同样是复用旧单继续付)。
真机核对(账号 hy_300546644):未支付单 #11353073 order_status=10、
pay_time=0、goods_detail.spu_id=hy-8328015;已取消单是 order_status=90。
查不到 / 接口不可用都返回空 dict。
"""
offset = 0
for _page in range(max(1, max_pages)):
resp = client.query_user_order_list(
uid=uid,
guid="",
cookie=cookie,
offset=offset,
page_size=page_size,
order_type=1,
status=0,
)
if resp is None:
return {}
orders = list(getattr(resp, "orders", None) or [])
for order in orders:
item = order.to_dict() if hasattr(order, "to_dict") else dict(order)
goods = item.get("goods_detail") or {}
if str(goods.get("spu_id") or "") != str(spu_id or ""):
continue
if int(item.get("pay_time") or 0) > 0:
continue
# 只认「待支付」:已取消(90)/已关闭(60) 的旧单付不了
if int(item.get("order_status") or 0) not in REUSABLE_ORDER_STATUSES:
continue
return item
total = int(getattr(resp, "totalCount", 0) or 0)
if len(orders) < page_size or offset + len(orders) >= total:
break
offset += len(orders)
return {}
def run_open_flow(
client: Any,
*,
uid: int,
cookie: str,
pid: int,
spu_id: str,
sku_id: int = 0,
count: int = 1,
pay_channel: str = "Zfb",
supplier_id: int = SUPPLIER_ID_YELLOW_CAR,
snapshot_price: int = 0,
log: Callable[[str], None] | None = None,
stopped: Callable[[], bool] | None = None,
) -> ShopOrderOutcome:
"""执行抓包链路;成功时返回「已生成支付二维码」的订单信息。"""
def emit(message: str) -> None:
if log is not None:
log(message)
def fail(message: str, **extra: Any) -> ShopOrderOutcome:
return ShopOrderOutcome(ok=False, message=message, **extra)
# 1) 商品详情(拿 skuId / 价格 / 商品名)
detail_resp = client.get_goods_info(
uid=uid,
guid="",
cookie=cookie,
pid=pid,
spu_id=spu_id,
sku_id=sku_id or 0,
game_id="",
)
if detail_resp is None:
return fail("虎牙充值商品详情接口无响应")
detail = detail_resp.to_dict()
if detail_resp.code != 200:
return fail(
detail_resp.message or f"虎牙充值商品详情获取失败: {detail_resp.code}",
detail=detail,
)
sku_id = int(detail.get("sku_id") or sku_id or 0)
product_name = str(detail.get("name") or spu_id)
unit_price = int(detail.get("price") or snapshot_price or 0)
if not sku_id:
return fail("充值商品缺少 SKU,请先刷新充值商品列表", detail=detail)
# 2) 供应商(小店)信息
supplier = client.get_supplier_info(uid=uid, guid="", cookie=cookie)
supplier_name = ""
if supplier is not None:
raw_name = supplier.value("tag2", "tag0")
supplier_name = raw_name if isinstance(raw_name, str) else ""
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
)
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,
)
# 4) 商城协议校验
protocol = client.check_hy_protocol(uid=uid, guid="", cookie=cookie)
if protocol is None or protocol.code != 200:
return fail(
getattr(protocol, "message", "") or "商城协议校验失败",
detail=detail,
supplier_name=supplier_name,
)
# 5) 返利/促销查询 + 下单试算
promotion_info = client.play_mall_get_my_promotion(
uid=uid,
guid="",
cookie=cookie,
spu_id=spu_id,
sku_id=sku_id,
price=unit_price,
)
promotion = client.calc_order_promotion(
uid=uid,
guid="",
cookie=cookie,
pid=pid,
spu_id=spu_id,
sku_id=sku_id,
item_count=count,
)
promotion_dict = promotion.to_dict() if promotion is not None else None
if promotion is not None and promotion.code not in (0, 200):
emit(f"[{uid}] 促销试算返回 {promotion.code}:{promotion.message}")
# 6) 同意商城协议(抓包:这一步在 checkUserBuyAuth 之前)
accept = client.accept_hy_protocol(uid=uid, guid="", cookie=cookie)
if accept is None or accept.code != 200:
return fail(
getattr(accept, "message", "") or "虎牙商城协议确认失败",
detail=detail,
supplier_name=supplier_name,
promotion=promotion_dict,
promotion_info=(
promotion_info.to_dict() if promotion_info is not None else None
),
)
if stopped is not None and stopped():
return fail("已停止", detail=detail, supplier_name=supplier_name)
common = {
"detail": detail,
"supplier_name": supplier_name,
"promotion": promotion_dict,
"promotion_info": (
promotion_info.to_dict() if promotion_info is not None else None
),
"sku_id": sku_id,
"unit_price": unit_price,
"count": count,
"product_name": product_name,
"pay_channel": pay_channel,
}
order_id = 0
order_raw: dict = {}
reused_order: dict = {}
# 7) 购买权限
#
# 限购商品(精英宝典 buy_limit=1)已经挂着未支付旧单时,**先失败的是这一步**:
# 真机 2026-10-15 实测 checkUserBuyAuth 直接返回 703「已超过该商品的购买上限,
# 下单失败~(如有未支付订单,请及时完成支付)」,createOrderV5 根本不会执行。
# 所以这里失败也要先找同商品的未支付旧单接着付,找不到才判失败。
auth = client.check_user_buy_auth(
uid=uid, guid="", cookie=cookie, spu_id=spu_id, sku_id=sku_id
)
if auth is None:
return fail("购买权限校验接口无响应", **common)
if auth.code != 200:
auth_message = auth.message or f"购买权限校验失败: {auth.code}"
reused_order = find_reusable_order(
client, uid=uid, cookie=cookie, spu_id=spu_id
)
if not reused_order or not reused_order.get("order_id"):
return fail(auth_message, **common)
order_id = int(reused_order["order_id"])
order_raw = dict(reused_order)
emit(f"[{uid}] {auth_message};改为复用未支付订单 #{order_id} 继续支付")
# 8) 下单(限购商品若已有未支付的旧单,就复用旧单发起支付,而不是判失败)
if not order_id:
order_resp = client.create_order(
uid=uid,
guid="",
cookie=cookie,
pid=pid,
spu_id=spu_id,
sku_id=sku_id,
item_count=count,
game_id="0",
order_type=6,
)
if order_resp is not None and order_resp.code == 200 and order_resp.orderId:
order_id = int(order_resp.orderId)
order_raw = order_resp.to_dict()
else:
create_message = (
order_resp.message if order_resp is not None else "虎牙下单接口无响应"
) or f"虎牙下单失败: {getattr(order_resp, 'code', 0)}"
reused_order = find_reusable_order(
client, uid=uid, cookie=cookie, spu_id=spu_id
)
if not reused_order or not reused_order.get("order_id"):
return fail(create_message, **common)
order_id = int(reused_order["order_id"])
order_raw = dict(reused_order)
emit(f"[{uid}] {create_message};改为复用未支付订单 #{order_id} 继续支付")
common["order_raw"] = order_raw
common["reused_order"] = reused_order
# 9) 起支付(出支付宝/微信二维码)
pay_resp = client.pay_order_submit(
uid=uid,
guid="",
cookie=cookie,
order_id=order_id,
pay_type=pay_channel,
pid=pid,
item_count=count,
)
if pay_resp is None:
return fail("虎牙支付接口无响应", **common)
pay_raw = pay_resp.to_dict()
common["pay_raw"] = pay_raw
if pay_resp.code != 200 or not pay_resp.payUrl:
return fail(
pay_resp.message or f"虎牙支付二维码生成失败: {pay_resp.code}",
**common,
)
amount = int(pay_resp.amount or unit_price * count or 0)
return ShopOrderOutcome(
ok=True,
message=(
f"已复用未支付订单 #{order_id}" if reused_order else "已生成支付二维码"
),
order_id=order_id,
app_order_id=pay_resp.appOrderId,
pay_order_id=pay_resp.payOrderId,
pay_url=pay_resp.payUrl,
amount=amount,
**common,
)