真机 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 商品被剔除、渠道失败透传、列表只认最近一套、刷新清理别处死行
346 lines
13 KiB
Python
346 lines
13 KiB
Python
"""虎牙积分中心(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,
|
||
)
|