Files
live-hub-py/web/backend/services/huya_runner_recharge.py
T
yml2213 bf1e2e9361 fix(huya): 支付二维码改用收银台页面里的扫码内容 qr.alipay.com
真机复现(2026-10-15):上一版把二维码内容放成了**收银台页面地址**
(excashier.alipay.com/standard/auth.htm?payOrderId=…),那是 PC 版页面
(页面里写死 qrcodeWidth=150)。有些安卓机是用系统相机/浏览器打开的,就停在
这张 PC 页上——只显示左上角一个很小的二维码,不触发付款。

收银台页面里真正的扫码内容是隐藏字段 `J_qrCode`:

    <input name="qrCode" value="https://qr.alipay.com/upx03110ghym9vm37sx7255c" id="J_qrCode"/>

- huya_pay_link 新增 PayTargets / resolve_pay_targets() / extract_pay_qr_code():
  跟随网关 302 取收银台页面并解析出 qr.alipay.com 扫码码(约 40 字符)。
- 出码时写入 pay_qr_code,保留 pay_cashier_url 兜底;拿不到扫码码会打 warning。
- 前端二维码优先级:pay_qr_code → pay_cashier_url → 本站短链 → 原始地址。

验证:全量 pytest 772 passed / 6 skipped;用真机那张收银台页面跑解析得到
https://qr.alipay.com/upx03110ghym9vm37sx7255c;typecheck / eslint / ruff 通过。
2026-10-15 19:51:51 +08:00

639 lines
25 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.
"""虎牙任务执行器:充值(由 huya_runner.py 按功能域拆分)。"""
from __future__ import annotations
import time
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy.orm import Session
from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM, SHOP_APP_SCENE
from ..models import HuyaAccount, HuyaRechargeGoodsSnapshot, HuyaTask
from .huya_pay_link import pay_qr_path, resolve_pay_targets
from .huya_shop_flow import run_open_flow
HUYA_RECHARGE_ACT_ID = 25135
# shopAppInfo.scene(2026-10-13 积分中心抓包实测 7;订单侧 scene 由
# core.huya.wss_client.ORDER_SCENE 决定,两者不是同一个值)
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积分)
# 旧版精英宝典 hy-5879340 页面上已不再使用,从候选里去掉。
# 旧活动接口 webActUI.getActTaskDetail(act 25135) 已下线(601 活动已下线),
# 商品来源不能再依赖它,只能作为可选补充。
HUYA_RECHARGE_BUILTIN_PRODUCTS = [
{
"spu_id": "hy-8328015",
"name": "精英宝典",
"task_name": "开通精英宝典",
"description": "得300积分丨解锁道具兑换权益",
"sort": 0,
},
{
"spu_id": "hy-8327356",
"name": "精英币",
"task_name": "购买精英币",
"description": "得1积分丨可兑指定返场皮肤道具",
"sort": 1,
},
{
"spu_id": "hy-5872824",
"name": "精英徽章",
"task_name": "购买精英徽章",
"description": "送10积分",
"sort": 2,
},
{
"spu_id": "hy-5879346",
"name": "精英令",
"task_name": "购买精英令",
"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
class RechargeMixin:
"""充值域:充值商品、下单与到账轮询。"""
if TYPE_CHECKING:
_stop: Any
payload: dict
@staticmethod
def _to_int(value: Any) -> int: ...
def _resolve_uid(self, account_info: dict) -> int: ...
def _push_log(self, level: str, message: str) -> None: ...
def _mark_task(self, *args: Any, **kwargs: Any) -> None: ...
def _update_task_progress(self, *args: Any, **kwargs: Any) -> None: ...
def _open_wss_session(self, uid: int, cookie: str, kind: str): ...
def _shop_client(self, uid: int, cookie: str): ...
def _refresh_elite_open_after_payment(
self,
client: Any,
account: HuyaAccount,
uid: int,
cookie: str,
*,
goods_num: int,
pay_time_ms: int | None = None,
attempts: int = 4,
interval: float = 3.0,
) -> dict: ...
@staticmethod
def _format_local_time(timestamp: int) -> str: ...
@staticmethod
def _huya_order_status_label(status: int) -> str:
from core.huya.shop_structs import OrderStatus
labels = {
OrderStatus.DEPOSIT_WAIT_PAY: "待支付",
OrderStatus.DEPOSIT_PAID: "已支付",
OrderStatus.WAIT_DELIVER: "待发货",
OrderStatus.WAIT_RECEIVE: "待收货",
OrderStatus.FINISHED: "已完成",
OrderStatus.FINISHED_CLOSED: "已关闭",
OrderStatus.CANCELLED: "已取消",
OrderStatus.BALANCE_WAIT_PAY: "尾款待支付",
OrderStatus.CANCELLED_BALANCE_EXPIRED: "尾款超时取消",
}
return labels.get(int(status or 0), str(status or "未知"))
@staticmethod
def _is_huya_order_paid(order) -> bool:
from core.huya.shop_structs import OrderStatus
paid_statuses = {
OrderStatus.DEPOSIT_PAID,
OrderStatus.WAIT_DELIVER,
OrderStatus.WAIT_RECEIVE,
OrderStatus.FINISHED,
OrderStatus.FINISHED_CLOSED,
}
return (
int(getattr(order, "payTime", 0) or 0) > 0
or int(getattr(order, "orderStatus", 0) or 0) in paid_statuses
)
def _wait_recharge_payment(
self,
client: Any,
uid: int,
guid: str,
cookie: str,
order_id: int,
sid: int,
result: dict,
) -> tuple[str, dict | None]:
deadline = time.time() + HUYA_PAYMENT_POLL_SECONDS
order_id_text = str(order_id)
last_order = None
while not self._stop.is_set() and time.time() < deadline:
detail = client.order_detail(
uid=uid, guid=guid, cookie=cookie, order_id=order_id, timeout=10.0
)
checked_at = datetime.now(UTC).isoformat()
order = getattr(detail, "order", None) if detail is not None else None
if order is not None and (
not getattr(order, "orderId", 0)
or str(getattr(order, "orderId", "")) == order_id_text
):
last_order = order.to_dict()
status = int(getattr(order, "orderStatus", 0) or 0)
result.update(
{
"payment_checked_at": checked_at,
"payment_order": last_order,
"payment_order_status": status,
"payment_order_status_label": self._huya_order_status_label(
status
),
}
)
if self._is_huya_order_paid(order):
result.update(
{
"payment_status": "paid",
"payment_status_label": "已支付",
"payment_paid": True,
"payment_paid_at": checked_at,
}
)
return "paid", last_order
else:
# 兼容旧 HTTP 网关不提供 orderDetailV5 的情况。
resp = client.query_user_order_list(
uid=uid,
guid=guid,
cookie=cookie,
offset=0,
page_size=10,
order_type=1,
status=0,
timeout=10.0,
)
for legacy_order in getattr(resp, "orders", []) if resp else []:
if str(getattr(legacy_order, "orderId", "")) != order_id_text:
continue
last_order = legacy_order.to_dict()
status = int(getattr(legacy_order, "orderStatus", 0) or 0)
result.update(
{
"payment_checked_at": checked_at,
"payment_order": last_order,
"payment_order_status": status,
"payment_order_status_label": self._huya_order_status_label(
status
),
}
)
if self._is_huya_order_paid(legacy_order):
result.update(
{
"payment_status": "paid",
"payment_status_label": "已支付",
"payment_paid": True,
"payment_paid_at": checked_at,
}
)
return "paid", last_order
result.setdefault("payment_checked_at", checked_at)
if self._stop.wait(HUYA_PAYMENT_POLL_INTERVAL):
break
result.update(
{
"payment_status": "timeout" if not self._stop.is_set() else "stopped",
"payment_status_label": "等待支付超时"
if not self._stop.is_set()
else "已停止监听",
"payment_paid": False,
"payment_timeout_seconds": HUYA_PAYMENT_POLL_SECONDS,
}
)
if last_order:
result["payment_order"] = last_order
return result["payment_status"], last_order
@staticmethod
def _normalize_pay_channel(value) -> str:
text = str(value or "").strip()
lowered = text.lower()
if lowered in {"weixin", "wx", "wechat", "微信"}:
return "Weixin"
return "Zfb"
@staticmethod
def _pay_channel_label(value: str) -> str:
return "微信" if value == "Weixin" else "支付宝"
@staticmethod
def _recharge_price_text(price: int | None) -> str:
if not price:
return ""
return f"{price / 100:.2f}元"
def _execute_refresh_recharge_goods(
self,
worker_db: Session,
task: HuyaTask,
account: HuyaAccount,
account_info: dict,
config_info: dict,
):
pid = self._to_int(config_info.get("room_pid"))
if not pid:
self._mark_task(worker_db, task, "failed", "请先配置虎牙直播间 ID")
return
uid = self._resolve_uid(account_info)
if not uid:
self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid")
return
cookie = account_info.get("cookie") or ""
if not cookie:
self._mark_task(worker_db, task, "failed", "账号 Cookie 为空")
return
client: Any = self._shop_client(uid, cookie)
candidates: list[dict] = []
seen: set[str] = set()
def add_candidate(item: dict):
spu_id = str(item.get("spu_id") or "").strip()
if not spu_id or spu_id in seen:
return
seen.add(spu_id)
candidates.append(item)
for item in HUYA_RECHARGE_BUILTIN_PRODUCTS:
add_candidate(dict(item))
# 旧活动任务接口(webActUI.getActTaskDetail,act 25135)已下线
# (真机 2026-10-15:601 活动已下线),只作为可选补充:
# 能返回就并入活动里的商品,失败不再阻断整次刷新。
task_result: dict | None = None
task_error = ""
task_resp = client.get_act_task_detail(
uid=uid, cookie=cookie, act_id=HUYA_RECHARGE_ACT_ID
)
if task_resp is None:
task_error = "活动任务详情接口无响应"
else:
task_result = task_resp.to_dict()
if task_resp.status == 200:
for index, item in enumerate(task_result.get("tasks", []), start=1):
if int(item.get("task_type") or 0) != 67:
continue
add_candidate(
{
"spu_id": item.get("spu_id") or "",
"name": item.get("name") or "",
"task_id": str(item.get("task_id") or ""),
"task_name": item.get("name") or "",
"description": item.get("description") or "",
"icon": item.get("icon") or "",
"task_url": item.get("task_url") or "",
"prizes": item.get("prizes") or [],
"sort": 1000 + index,
}
)
else:
task_error = (
task_resp.msg or f"活动任务详情获取失败: {task_resp.status}"
)
if task_error:
self._push_log(
"warning",
f"[{uid}] 活动任务接口不可用({task_error}),按内置开通商品刷新",
)
if not candidates:
self._mark_task(
worker_db, task, "failed", "未发现可用的开通商品", task_result or {}
)
return
now = datetime.now(UTC)
goods: list[dict] = []
failed: list[dict] = []
for candidate in candidates:
spu_id = candidate["spu_id"]
detail_resp = client.get_goods_info(
uid=uid,
guid="",
cookie=cookie,
pid=pid,
spu_id=spu_id,
sku_id=0,
game_id="",
source_id=HUYA_RECHARGE_SOURCE_ID,
app_scene=HUYA_RECHARGE_APP_SCENE,
)
if detail_resp is None:
failed.append({"spu_id": spu_id, "message": "商品详情接口无响应"})
continue
detail = detail_resp.to_dict()
if detail_resp.code != 200 or not detail.get("sku_id"):
failed.append(
{
"spu_id": spu_id,
"message": detail_resp.message
or f"商品详情获取失败: {detail_resp.code}",
"detail": detail,
}
)
continue
item = {
**candidate,
**detail,
"spu_id": detail.get("spu_id") or spu_id,
"sku_id": str(detail.get("sku_id") or ""),
"name": detail.get("name") or candidate.get("name") or spu_id,
"description": detail.get("description")
or candidate.get("description")
or "",
"icon": detail.get("icon") or candidate.get("icon") or "",
"task_id": candidate.get("task_id") or "",
"task_name": candidate.get("task_name") or candidate.get("name") or "",
"raw_order": int(candidate.get("sort") or 0),
}
goods.append(item)
# 清掉该账号的全部旧商品行(不限 sid):否则跨 sid 的旧行会和本次结果
# 在列表接口里叠成同一商品的重复项(如 精英宝典 x2)。
worker_db.query(HuyaRechargeGoodsSnapshot).filter(
HuyaRechargeGoodsSnapshot.account_id == account.id,
).delete(synchronize_session=False)
for item in goods:
worker_db.add(
HuyaRechargeGoodsSnapshot(
account_id=account.id,
sid=self._to_int(config_info.get("sid")) or 2203,
spu_id=item["spu_id"],
sku_id=item["sku_id"],
name=item["name"],
price=item.get("price") or None,
stock=item.get("stock") or None,
buy_limit=item.get("buy_limit") or None,
icon=item.get("icon") or "",
description=item.get("description") or "",
task_id=item.get("task_id") or "",
task_name=item.get("task_name") or "",
raw=item,
updated_at=now,
)
)
account.status = "recharge_goods_refreshed"
account.updated_at = now
result = {
"act_id": HUYA_RECHARGE_ACT_ID,
"goods_count": len(goods),
"failed_count": len(failed),
"goods": goods,
"failed": failed,
"task_detail": task_result,
"task_detail_error": task_error,
}
if not goods:
reason = failed[0].get("message") if failed else "无候选商品"
self._mark_task(
worker_db, task, "failed", f"开通商品刷新失败: {reason}", result
)
return
message = f"已刷新开通商品 {len(goods)} 个"
if failed:
message += f",失败 {len(failed)} 个"
self._mark_task(worker_db, task, "success", message, result)
def _execute_create_recharge_order(
self,
worker_db: Session,
task: HuyaTask,
account: HuyaAccount,
account_info: dict,
config_info: dict,
):
pid = self._to_int(config_info.get("room_pid"))
if not pid:
self._mark_task(worker_db, task, "failed", "请先配置虎牙直播间 ID")
return
spu_id = str(self.payload.get("spu_id") or "").strip()
if not spu_id:
self._mark_task(worker_db, task, "failed", "请选择充值商品")
return
count = self._to_int(self.payload.get("count")) or 1
count = max(1, min(count, 999))
pay_channel = self._normalize_pay_channel(
self.payload.get("pay_channel") or config_info.get("pay_channel")
)
uid = self._resolve_uid(account_info)
if not uid:
self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid")
return
cookie = account_info.get("cookie") or ""
if not cookie:
self._mark_task(worker_db, task, "failed", "账号 Cookie 为空")
return
snapshot = (
worker_db.query(HuyaRechargeGoodsSnapshot)
.filter(
HuyaRechargeGoodsSnapshot.account_id == account.id,
HuyaRechargeGoodsSnapshot.sid
== (self._to_int(config_info.get("sid")) or 2203),
HuyaRechargeGoodsSnapshot.spu_id == spu_id,
)
.first()
)
payload_sku_id = self._to_int(self.payload.get("sku_id"))
sku_id = payload_sku_id or self._to_int(snapshot.sku_id if snapshot else "")
product_name = str(
self.payload.get("product_name")
or (snapshot.name if snapshot else "")
or spu_id
)
unit_price = int(snapshot.price or 0) if snapshot else 0
client: Any = self._shop_client(uid, cookie)
outcome = run_open_flow(
client,
uid=uid,
cookie=cookie,
pid=pid,
spu_id=spu_id,
sku_id=sku_id,
count=count,
pay_channel=pay_channel,
snapshot_price=unit_price,
log=lambda message: self._push_log("info", message),
stopped=self._stop.is_set,
)
detail = outcome.detail
if not outcome.ok:
self._mark_task(
worker_db,
task,
"error" if "无响应" in outcome.message else "failed",
outcome.message,
{
"goods": detail,
"supplier_name": outcome.supplier_name,
"promotion": outcome.promotion,
"promotion_info": outcome.promotion_info,
},
)
return
sku_id = outcome.sku_id or sku_id
product_name = outcome.product_name or product_name
unit_price = outcome.unit_price or unit_price
order_result = outcome.order_raw
pay_result = outcome.pay_raw
amount = outcome.amount
# 二维码内容优先级(见 huya_pay_link 模块头):
# ① 支付宝扫码支付码 https://qr.alipay.com/xxx —— 手机扫码直接进付款;
# ② 收银台直达地址(PC 版页面,拿不到 ① 时才用,有些安卓机扫开只看到小二维码);
# ③ 本站短链(要求本页域名手机能访问)。
pay_targets = resolve_pay_targets(outcome.pay_url)
if pay_targets.qr_code:
self._push_log("info", f"[{uid}] 支付二维码使用支付宝扫码支付码")
elif pay_targets.cashier_url:
self._push_log(
"warning",
f"[{uid}] 未取到支付宝扫码支付码,二维码回落到收银台地址(部分手机扫开不触发付款)",
)
else:
self._push_log(
"warning", f"[{uid}] 未解析到支付宝收银台地址,二维码回落到本站短链"
)
result = {
"spu_id": spu_id,
"sku_id": sku_id,
"product_name": product_name,
"count": count,
"unit_price": unit_price,
"amount": amount,
"amount_text": self._recharge_price_text(amount),
"pay_channel": pay_channel,
"pay_channel_label": self._pay_channel_label(pay_channel),
"order_id": outcome.order_id,
"app_order_id": outcome.app_order_id,
"pay_order_id": outcome.pay_order_id,
"pay_url": outcome.pay_url,
# 支付地址 1600+ 字符,直接编成二维码扫不动(真机反馈);给前端两条更短的路径:
# pay_qr_code = 收银台页面里的 https://qr.alipay.com/xxx(约 40 字符,扫码即付款);
# pay_cashier_url = 收银台直达地址(PC 版页面);
# pay_qr_path = 本站短链 /api/huya/pay/<task_id>?k=...(需本页域名手机可达)。
"pay_qr_code": pay_targets.qr_code,
"pay_cashier_url": pay_targets.cashier_url,
"pay_qr_path": pay_qr_path(task.id) if task is not None else "",
# 复用未支付旧单时为那张单的原始信息(限购商品:旧单没付完就下不了新单)
"reused_unpaid_order": bool(outcome.reused_order),
"reused_order": outcome.reused_order or None,
"payment_status": "pending",
"payment_status_label": "等待支付",
"payment_paid": False,
"goods": detail,
"order": order_result,
"pay": pay_result,
"supplier_name": outcome.supplier_name,
"promotion": outcome.promotion,
"promotion_info": outcome.promotion_info,
}
account.status = "recharge_order_created"
account.updated_at = datetime.now(UTC)
message = f"{product_name} x{count} {self._pay_channel_label(pay_channel)} {result['amount_text']}"
if result.get("reused_unpaid_order"):
message += f"(复用未支付订单 #{result['order_id']})"
self._update_task_progress(
worker_db, task, "running", f"{message},等待扫码支付", result
)
self._push_log(
"info", f"[{uid}] 已生成虎牙支付二维码,开始监听订单 {outcome.order_id}"
)
payment_status, payment_order = self._wait_recharge_payment(
client=client,
uid=uid,
guid="",
cookie=cookie,
order_id=outcome.order_id,
sid=self._to_int(config_info.get("sid")) or 2203,
result=result,
)
account.updated_at = datetime.now(UTC)
if payment_status == "paid":
account.status = "recharge_paid"
# 支付后自动确认宝典开通状态(顺带取最新积分):交付有几秒延迟,
# _refresh_elite_open_after_payment 会重试到 tag5 变成已开通。
elite_state = self._refresh_elite_open_after_payment(
client,
account,
uid,
cookie,
goods_num=self._to_int(self.payload.get("goods_num"))
or POINTS_REDEEM_GOODS_NUM,
pay_time_ms=(
self._to_int(payment_order.get("pay_time"))
if payment_order
else None
),
)
result.update(elite_state)
if "redeem_points" in elite_state:
result["post_payment_points"] = elite_state["redeem_points"]
account.points = elite_state["redeem_points"]
paid_message = f"支付成功: {product_name} x{count} {result['amount_text']}"
if payment_order and payment_order.get("pay_time"):
paid_message += f",支付时间 {self._format_local_time(int(payment_order['pay_time']) // 1000)}"
self._mark_task(worker_db, task, "success", paid_message, result)
return
if payment_status == "stopped":
account.status = "recharge_order_created"
self._mark_task(
worker_db, task, "stopped", f"{message},已停止监听支付", result
)
return
account.status = "recharge_order_created"
timeout_message = f"{message},{result['payment_status_label']}"
self._mark_task(worker_db, task, "timeout", timeout_message, result)