Files
live-hub-py/web/backend/services/huya_runner_recharge.py
T

673 lines
27 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
# 内置开通商品候选。**必须同时满足** getGoodsInfoV5=200(能看到)与
# listPayChannelV5=200(能买到)——真机(2026-10-15,hy_300546644 / uid 1199667339402):
#
# 可购买(两个接口都 200,支付渠道 ["Zfb","Weixin"]):
# hy-8328015 精英宝典 ¥30(sku 7756811,2026-10-13 抓包下单商品)
# hy-8327356 精英币 ¥0.1(sku 7759407)
# hy-8343549 精英徽章 ¥1 (sku 7750721,10.15「精英徽章两个」抓包下单商品)
# hy-8343551 精英令 ¥10(sku 7750723)
# 不可购买(getGoodsInfoV5 仍返回 200 + 价格,但 listPayChannelV5 返回
# 401「您的设备环境暂不支持购买该商品」)——这是积分中心「赠送指定礼物得积分」
# 那一排的礼物 spu,只能送礼物拿积分,不能当商品下单:
# hy-5872824 精英徽章 ¥1、hy-5879346 精英令 ¥10、
# hy-5874269 精英令*10 ¥100、hy-5874270 精英令*100 ¥1000
# (这就是之前多选数量下单报「当前支付渠道不可用」的原因:面板里选的是礼物 spu。
# 精英令*10/*100 暂未找到可购买的 spu,等抓到对应页面再补。)
#
# 旧版精英宝典 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-8343549",
"name": "精英徽章",
"task_name": "购买精英徽章",
"description": "得10积分丨可兑指定返场皮肤道具",
"sort": 2,
},
{
"spu_id": "hy-8343551",
"name": "精英令",
"task_name": "购买精英令",
"description": "得100积分",
"sort": 3,
},
]
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
# 商品详情 200 只代表「能看到」:积分中心那批礼物 spu(hy-5872824 等)在这里
# 也能返回价格,但 listPayChannelV5 会返回 401
# 「您的设备环境暂不支持购买该商品」,选它下单必然失败。用支付渠道当
# 「能买到」的闸门,只留真正可下单的商品(真机 2026-10-15)。
channels = client.list_pay_channels(
uid=uid,
guid="",
cookie=cookie,
spu_id=spu_id,
sku_id=0,
)
if channels is None:
failed.append({"spu_id": spu_id, "message": "支付渠道接口无响应"})
continue
if channels.code != 200 or not getattr(channels, "channels", None):
failed.append(
{
"spu_id": spu_id,
"message": channels.message
or f"该商品当前不可购买(支付渠道 {channels.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 的旧行会和本次结果叠成重复项);
# ② 其它账号里已经不在候选里的 spu(真机 2026-10-15:账号 65/66 还留着修复前的
# 礼物 spu hy-5872824 等,会和最新一套并集成 7 个选项出现在面板里);
# ③ 2026-07-04 的全局默认行(account_id 为空,早已不是在售商品)。
kept_spus = {item["spu_id"] for item in goods}
worker_db.query(HuyaRechargeGoodsSnapshot).filter(
HuyaRechargeGoodsSnapshot.account_id == account.id,
).delete(synchronize_session=False)
worker_db.query(HuyaRechargeGoodsSnapshot).filter(
HuyaRechargeGoodsSnapshot.account_id.is_(None),
).delete(synchronize_session=False)
if kept_spus:
worker_db.query(HuyaRechargeGoodsSnapshot).filter(
HuyaRechargeGoodsSnapshot.account_id.is_not(None),
HuyaRechargeGoodsSnapshot.account_id != account.id,
HuyaRechargeGoodsSnapshot.spu_id.notin_(kept_spus),
).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, 10000))
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)