Files
live-hub-py/web/backend/services/huya_runner_elite.py
T
yml2213 73536d62c8 feat(huya): 工作台补手机号/精英宝典列与一键查询
- 手机号列:外部 CK 的 `----手机号` 本来就落在 huya_accounts.game_phone,
  列表一直没展示;现在单独成列,并纳入搜索
- 精英宝典列:显示「已开通/未开通/未查询」+ 开通时间 + 有效期
  · 开通状态 = 积分中心 getRedeemNeedGoods 的 tag5(真机对照网页:1=已开通,
    0=未开通,此时 tag7 会列出开通套餐),拿不准时为 NULL(显示「未查询」)
  · 开通时间 = revenueWebUI.queryUserOrderList 里已支付的宝典订单 pay_time
    (按 SPU 白名单 + 订单名含「精英宝典」识别,未支付订单不计,取最近一次)
  · 有效期 = PlayMallUI.queryAllocation 页面配置的本赛季节点 tag10/tag11
    (2026-10-15 抓包:精英宝典福利季丨Q4,2026-10-09 ~ 2026-12-31 23:59:59),
    不写死索引,递归找带 tag10/tag11 的节点
- 新任务 query_all「一键查询」:勾选账号一次跑完角色绑定 + 宝典开通 + 积分,
  按部分失败如实汇总(某个接口挂了不影响其它结论),并把宝典状态写回账号
- 迁移 20261015_0053:huya_accounts 新增 elite_opened / elite_open_time /
  elite_expire_time(时间存 Unix 秒,与斗鱼 elite_expire_time 同约定)
- 开通商品按页面「赠送指定礼物得积分」那一排对齐成 6 款(去掉页面已不用的
  旧版精英宝典 hy-5879340),列表接口只返回账号级快照并按 spu 去重
- 建 WSS 会话失败(积分中心端点静默不回包)时回退 HTTP cdnws,
  不再让整批任务判失败
- 列表列宽改按比例分配,避免宽屏下被拉出奇怪的宽度;最近操作不再重复状态
- 新增 tests/test_huya_elite_query.py(10 条);同步迁移头版本断言

真机验证:#65 已开通(10-13 20:41:45,有效期 12-31)、#70 新开通
(10-15 16:37:37)判定与订单/网页一致
2026-10-15 16:53:30 +08:00

337 lines
14 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
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 _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)