Files
live-hub-py/web/backend/services/huya_runner_elite.py
T
yml2213 f61284e808 feat(huya): 开通支付码支持多选批量 + 支付后自动确认开通
多选账号点「生成开通支付码」以前只有弹窗里最后一个码能被看到(每个账号一个
任务,前端只留一个 qrTask),实际只能一个账号一个账号地开。

- 前端:支付码弹窗改成列表 —— 一次列出该批次所有账号的二维码(账号/UID/
  商品金额/状态),未支付的排前面,可逐个扫也可一起扫;行点击也会打开对应批次
- 支付成功后自动确认开通:新增 EliteMixin._refresh_elite_open_after_payment,
  交付有几秒延迟,tag5 还没翻成 1 时重试(默认 4 次 × 3 秒),顺带取最新积分,
  并把 elite_opened/elite_open_time/elite_expire_time 写回账号
- 已支付或已开通的账号二维码自动从弹窗收起,只留一行「已开通」标签
- 开通时间优先用支付回调里的支付时间(比再去翻订单更准,也少一次请求)
- 补 3 条测试:重试到已开通、重试到上限、支付成功后任务结果与账号字段齐全

全量 746 passed / 6 skipped,前端 tsc 通过
2026-10-15 17:14:20 +08:00

394 lines
16 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 按功能域拆分)。
真机(2026-10-15)核对过的判定来源:
* 宝典开通状态 —— 积分中心 ``shopMiddleUI.getRedeemNeedGoods`` 的 ``tag5``:
``1`` = 已开通(网页显示「已开通精英宝典」),``0`` = 未开通(网页显示
「获取积分需先开通精英宝典」,``tag7`` 还会列出开通套餐)。
两个已开通账号(hy_297832664 / hy_302016799)与两个未开通账号
(hy_260034554 / hy_274193170)实测与网页一致。
* 开通时间 —— ``revenueWebUI.queryUserOrderList`` 里已支付的宝典订单,取最近一次支付时间。
* 有效期 —— 商城订单(orderDetailV5)与积分中心都**不返回**到期时间,所以留空,
界面上「有就显示」。
"""
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
from ..models import HuyaAccount, HuyaTask
# 宝典订单识别:spu 白名单 + 订单名兜底(官方换过多次 SKU,历史订单名带赛季,
# 例如「精英宝典⎜得300积分⎜26年Q2」)。
ELITE_HANDBOOK_SPU_IDS = frozenset(
{"hy-8328015", "hy-5879340", "hy-3845257", "hy-2947756"}
)
ELITE_HANDBOOK_NAME_KEYWORD = "精英宝典"
ELITE_ORDER_PAGE_SIZE = 20
ELITE_ORDER_MAX_PAGES = 3
# 赛季(福利季)节点的毫秒时间戳下限:2001-09 之后的值才认
ELITE_SEASON_MS_FLOOR = 1_000_000_000_000
class EliteMixin:
"""精英宝典域:开通状态判定与一键查询。"""
if TYPE_CHECKING:
payload: dict
@staticmethod
def _to_int(value: Any) -> int: ...
@staticmethod
def _format_local_time(timestamp: int) -> str: ...
def _resolve_uid(self, account_info: dict) -> int: ...
def _shop_client(self, uid: int, cookie: str): ...
def _activity_client(self, uid: int, cookie: str): ...
def _mark_task(self, *args: Any, **kwargs: Any) -> None: ...
# 来自 BindMixin
@staticmethod
def _bind_role_result(bind_status) -> dict: ...
@staticmethod
def _bind_change_state(bind_status) -> dict: ...
@staticmethod
def _role_channel(bind_status) -> str: ...
def _resolve_bind_status(
self, client: Any, uid: int, cookie: str, b_act_id_int: int
): ...
@staticmethod
def _elite_opened_from_redeem(redeem: Any) -> bool | None:
"""``getRedeemNeedGoods`` 的 tag5 → 开通状态;拿不准时返回 None(不猜)。"""
if redeem is None or getattr(redeem, "code", 0) != 200:
return None
flag = redeem.value("tag5")
if isinstance(flag, bool) or not isinstance(flag, int):
return None
return flag == 1
@staticmethod
def _is_elite_order(item: dict) -> bool:
detail = item.get("goods_detail") or {}
if str(detail.get("spu_id") or "") in ELITE_HANDBOOK_SPU_IDS:
return True
return ELITE_HANDBOOK_NAME_KEYWORD in str(item.get("item_name") or "")
def _resolve_elite_open_time(
self, client: Any, uid: int, cookie: str
) -> int | None:
"""最近一次已支付宝典订单的时间(Unix 毫秒);查不到返回 None。
订单列表按时间倒序分页返回,宝典订单可能落在后面,所以最多翻
``ELITE_ORDER_MAX_PAGES`` 页;中途任何一次失败都按「没查到」处理。
未支付订单(真机:order_status=10)没有 pay_time,自然不参与。
"""
latest = 0
offset = 0
for _page in range(ELITE_ORDER_MAX_PAGES):
resp = client.query_user_order_list(
uid=uid,
guid="",
cookie=cookie,
offset=offset,
page_size=ELITE_ORDER_PAGE_SIZE,
order_type=1,
status=0,
)
if resp is None:
break
orders = list(getattr(resp, "orders", None) or [])
for order in orders:
item = order.to_dict() if hasattr(order, "to_dict") else dict(order)
if not self._is_elite_order(item):
continue
pay_time = int(item.get("pay_time") or 0)
if pay_time > 0:
latest = max(latest, pay_time)
total = self._to_int(getattr(resp, "totalCount", 0))
if len(orders) < ELITE_ORDER_PAGE_SIZE or offset + len(orders) >= total:
break
offset += len(orders)
return latest or None
@classmethod
def _walk_season_nodes(cls, node: Any, found: list[dict]) -> None:
"""递归找「赛季」节点:同时带 tag10/tag11 毫秒起止时间的结构。"""
if isinstance(node, dict):
start = node.get("tag10")
end = node.get("tag11")
if (
isinstance(start, int)
and isinstance(end, int)
and start >= ELITE_SEASON_MS_FLOOR
and end > start
):
found.append(
{
"name": str(node.get("tag2") or node.get("tag1") or ""),
"start_ms": start,
"end_ms": end,
"config_id": node.get("tag0")
if isinstance(node.get("tag0"), int)
else None,
}
)
for value in node.values():
cls._walk_season_nodes(value, found)
elif isinstance(node, list):
for value in node:
cls._walk_season_nodes(value, found)
@classmethod
def _extract_elite_season(cls, payload: Any) -> dict | None:
"""从积分中心页面配置里取宝典赛季(= 有效期)信息。
真机(2026-10-15 抓包)实证:``PlayMallUI.queryAllocation`` 响应里
``tag2[1].tag2_2.tag0`` 是本赛季配置 —— tag1/tag2 = 「精英宝典福利季」/
「精英宝典福利季丨Q4」,tag10/tag11 = 赛季起止毫秒时间戳
(1791475200000 → 2026-10-09 00:00,1798732799000 → 2026-12-31 23:59:59)。
这里不写死索引,按「带 tag10/tag11 的节点」找,取结束时间最晚的那个。
"""
found: list[dict] = []
cls._walk_season_nodes(payload, found)
if not found:
return None
return max(found, key=lambda item: item["end_ms"])
def _resolve_elite_season(self, client: Any, uid: int, cookie: str) -> dict | None:
"""查询积分中心页面配置并解析本赛季信息;查不到返回 None。"""
resp = client.play_mall_query_allocation(uid=uid, guid="", cookie=cookie)
if resp is None or getattr(resp, "code", 0) != 200:
return None
return self._extract_elite_season(getattr(resp, "payload", None))
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:
"""支付完成后确认宝典是否已开通,并写回账号。
交付有几秒延迟,所以 ``tag5`` 还没翻成 1 时会重试(默认 4 次 × 3 秒)。
返回可直接并入任务结果的 dict(含 ``redeem_points``,省掉一次重复查询)。
"""
redeem = None
opened: bool | None = None
total = max(1, attempts)
for index in range(total):
redeem = client.get_redeem_need_goods(
uid=uid, guid="", cookie=cookie, goods_num=int(goods_num or 0)
)
opened = self._elite_opened_from_redeem(redeem)
if opened or index == total - 1:
break
time.sleep(interval)
state: dict[str, Any] = {"elite_checked_at": datetime.now(UTC).isoformat()}
if redeem is not None and getattr(redeem, "code", 0) == 200:
state["redeem"] = redeem.to_dict()
state["redeem_points"] = self._to_int(redeem.value("tag2"))
if opened is None:
return state
open_time: int | None = pay_time_ms or None
if opened and not open_time:
open_time = self._resolve_elite_open_time(client, uid, cookie)
season = self._resolve_elite_season(client, uid, cookie) if opened else None
expire_time = self._to_int(season["end_ms"]) if season else None
self._apply_elite_state(account, opened, open_time, expire_time)
state["elite_opened"] = opened
state["elite_open_time"] = (open_time // 1000) if open_time else None
state["elite_open_time_text"] = (
self._format_local_time(open_time // 1000) if open_time else ""
)
if season:
state["elite_season"] = season
state["elite_expire_time"] = expire_time // 1000
state["elite_expire_time_text"] = self._format_local_time(
expire_time // 1000
)
return state
def _apply_elite_state(
self,
account: HuyaAccount,
opened: bool | None,
open_time: int | None,
expire_time: int | None = None,
) -> None:
"""把宝典状态写到账号上;``opened`` 为 None(没查到)时不动旧值。
库里存 **Unix 秒**(Integer 放不下毫秒级时间戳,且与斗鱼一致),
方法内部一路用的是毫秒(协议原值),落库时统一除 1000。
"""
if opened is None:
return
account.elite_opened = opened
account.elite_open_time = (
(int(open_time) // 1000 if open_time else None) if opened else None
)
account.elite_expire_time = (
(int(expire_time) // 1000 if expire_time else None) if opened else None
)
def _execute_query_all(
self,
worker_db: Session,
task: HuyaTask,
account: HuyaAccount,
account_info: dict,
config_info: dict,
):
"""一键查询:角色绑定 + 精英宝典开通 + 积分(勾选账号批量跑)。"""
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
result: dict[str, Any] = {"uid": uid}
failures: list[str] = []
message_parts: list[str] = []
# ---- 积分 + 宝典开通(积分中心同一个接口) ----
shop_client: Any = self._shop_client(uid, cookie)
goods_num = (
self._to_int(self.payload.get("goods_num")) or POINTS_REDEEM_GOODS_NUM
)
redeem = shop_client.get_redeem_need_goods(
uid=uid, guid="", cookie=cookie, goods_num=goods_num
)
points: int | None = None
opened: bool | None = None
if redeem is None:
failures.append("积分中心接口无响应")
elif redeem.code != 200:
failures.append(redeem.message or f"积分查询失败: {redeem.code}")
else:
points = self._to_int(redeem.value("tag2"))
opened = self._elite_opened_from_redeem(redeem)
result.update(
{
"points": points,
"redeem_threshold": self._to_int(redeem.value("tag3")),
"goods_num": goods_num,
"redeem": redeem.to_dict(),
}
)
message_parts.append(f"积分 {points}")
open_time: int | None = None
if opened:
# 已开通才去翻订单:既省一次请求,也避免给未开通账号显示旧订单时间
open_time = self._resolve_elite_open_time(shop_client, uid, cookie)
season = (
self._resolve_elite_season(shop_client, uid, cookie) if opened else None
)
expire_time = self._to_int(season["end_ms"]) if season else None
if opened is not None:
self._apply_elite_state(account, opened, open_time, expire_time)
result["elite_opened"] = opened
result["elite_open_time"] = (open_time // 1000) if open_time else None
result["elite_open_time_text"] = (
self._format_local_time(open_time // 1000) if open_time else ""
)
if season:
result["elite_season"] = season
result["elite_expire_time"] = expire_time // 1000
result["elite_expire_time_text"] = self._format_local_time(
expire_time // 1000
)
if opened:
opened_text = "已开通宝典"
if open_time:
opened_text += f"({self._format_local_time(open_time // 1000)})"
if expire_time:
opened_text += (
f",有效期至 {self._format_local_time(expire_time // 1000)}"
)
else:
opened_text = "未开通宝典"
message_parts.append(opened_text)
# ---- 角色绑定(活动通道) ----
b_act_id_int = self._to_int(
self.payload.get("bind_act_id") or config_info.get("bind_act_id")
)
role_name = ""
if not b_act_id_int:
failures.append("未配置绑定 bActId")
else:
bind_client: Any = self._activity_client(uid, cookie)
bind_status, bind_query_result = self._resolve_bind_status(
client=bind_client,
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
)
if bind_status is None:
failures.append("角色信息接口无响应")
elif bind_status.status != 200:
failures.append(
bind_status.msg or f"角色信息查询失败: {bind_status.status}"
)
else:
role_info = self._bind_role_result(bind_status)
role_name = role_info.get("role_name") or ""
result.update(
{
"bind_act_id": b_act_id_int,
**role_info,
**self._bind_change_state(bind_status),
**bind_query_result,
}
)
if role_name:
account.game_name = role_name
account.game_channel = (
self._role_channel(bind_status) or account.game_channel
)
message_parts.append(f"角色 {role_name}")
else:
message_parts.append("未绑定角色")
account.points = points if points is not None else account.points
account.status = "all_queried"
account.updated_at = datetime.now(UTC)
result["failures"] = failures
fetched_anything = points is not None or role_name or opened is not None
if not fetched_anything:
self._mark_task(
worker_db,
task,
"failed",
";".join(failures) or "一键查询全部失败",
result,
)
return
message = "|".join(message_parts) or "一键查询完成"
if failures:
message += f";部分失败: {';'.join(failures)}"
self._mark_task(worker_db, task, "success", message, result)