438 lines
18 KiB
Python
438 lines
18 KiB
Python
"""虎牙任务执行器:精英宝典开通状态与「一键查询」(由 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,
|
||
):
|
||
"""一键查询:先验 CK,再查角色绑定 + 精英宝典开通 + 积分。
|
||
|
||
与「查询角色 / 查询积分」保持同一套最新链路:
|
||
- CK 走 HTTP 探针 + outer_act_id + 15 秒;无回包立即判失效并返回;
|
||
- 积分/宝典直接走 HTTP,不再为一次查询建 WSS 商城会话。
|
||
"""
|
||
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] = []
|
||
role_name = ""
|
||
|
||
# ---- 角色绑定 / CK 有效性(先做,失效就不空跑后面两个接口) ----
|
||
b_act_id_int = self._to_int(
|
||
self.payload.get("bind_act_id") or config_info.get("bind_act_id")
|
||
)
|
||
if not b_act_id_int:
|
||
failures.append("未配置绑定 bActId")
|
||
result["ck_valid"] = False
|
||
else:
|
||
outer_act_id = str(
|
||
self.payload.get("outer_act_id")
|
||
or config_info.get("outer_act_id")
|
||
or ""
|
||
).strip()
|
||
bind_status, bind_query_result = self._probe_bind_status(
|
||
uid=uid,
|
||
cookie=cookie,
|
||
b_act_id_int=b_act_id_int,
|
||
outer_act_id=outer_act_id,
|
||
)
|
||
if bind_status is None:
|
||
account.status = "ck_expired"
|
||
account.updated_at = datetime.now(UTC)
|
||
result.update(
|
||
{
|
||
"bind_act_id": b_act_id_int,
|
||
"failures": failures + ["角色信息接口无响应"],
|
||
"ck_expired": True,
|
||
"ck_expired_reason": "角色信息接口无响应",
|
||
}
|
||
)
|
||
self._mark_task(
|
||
worker_db,
|
||
task,
|
||
"error",
|
||
"虎牙角色信息接口无响应(CK 已失效,请重新登录或重新导入 Cookie)",
|
||
result,
|
||
)
|
||
return
|
||
result.update(
|
||
{
|
||
"bind_act_id": b_act_id_int,
|
||
"ck_valid": int(getattr(bind_status, "status", 0) or 0) == 200,
|
||
}
|
||
)
|
||
if 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(
|
||
{
|
||
**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("未绑定角色")
|
||
|
||
# ---- 积分 + 宝典开通(积分中心同一个接口,直接 HTTP) ----
|
||
shop_client: Any = self._http_client()
|
||
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,
|
||
timeout=10.0,
|
||
)
|
||
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)
|
||
|
||
account.points = points if points is not None else account.points
|
||
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
|
||
|
||
account.status = "all_queried"
|
||
account.updated_at = datetime.now(UTC)
|
||
message = "|".join(message_parts) or "一键查询完成"
|
||
if failures:
|
||
message += f";部分失败: {';'.join(failures)}"
|
||
self._mark_task(worker_db, task, "success", message, result)
|