- 手机号列:外部 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)判定与订单/网页一致
337 lines
14 KiB
Python
337 lines
14 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)与积分中心都**不返回**到期时间,所以留空,
|
||
界面上「有就显示」。
|
||
"""
|
||
|
||
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)
|