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

419 lines
17 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)与积分中心都**不返回**到期时间,所以留空,
界面上「有就显示」。
* CK 失效 —— 「一键查询」里角色走活动通道(``webActUI.checkUserBindGameAccount``),
真机(2026-10-15,外部导入的过期 CK)表现为**整个请求没有 tRsp 回包**
(日志里 ``响应 newdata keys: ['']``),于是 ``_resolve_bind_status`` 返回 None。
这种情况按「CK 已失效」处理:任务 result 打 ``ck_expired`` 标记、账号状态置
``ck_expired``,前端把该行标红提示重新登录/重新导入 Cookie。
积分中心走另一条通道(baseinfo 里的 cookie 校验更松),过期 CK 仍可能返回 200,
所以不能拿积分能不能查来判 CK 有效性。
"""
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 _http_client(self): ...
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 = ""
# 角色查不到 = 这个 CK 已经不认了(模块头里有真机依据),整行要标红提醒
ck_expired = False
ck_expired_reason = ""
if not b_act_id_int:
failures.append("未配置绑定 bActId")
else:
outer_act_id = str(
self.payload.get("outer_act_id")
or config_info.get("outer_act_id")
or ""
).strip()
bind_client: Any = self._http_client()
bind_status, bind_query_result = self._resolve_bind_status(
client=bind_client,
uid=uid,
cookie=cookie,
b_act_id_int=b_act_id_int,
outer_act_id=outer_act_id,
timeout=15.0,
)
if bind_status is None:
ck_expired = True
ck_expired_reason = "角色信息接口无响应"
failures.append(ck_expired_reason)
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
# CK 失效时账号状态单独标出来(前端「状态」列据此显示红色「CK 失效」)
account.status = "ck_expired" if ck_expired else "all_queried"
account.updated_at = datetime.now(UTC)
result["failures"] = failures
if ck_expired:
result["ck_expired"] = True
result["ck_expired_reason"] = ck_expired_reason
ck_hint = ";疑似 CK 已失效,请重新登录或重新导入 Cookie" if ck_expired else ""
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 "一键查询全部失败") + ck_hint,
result,
)
return
message = "|".join(message_parts) or "一键查询完成"
if failures:
message += f";部分失败: {';'.join(failures)}"
self._mark_task(worker_db, task, "success", message + ck_hint, result)