"""虎牙任务执行器:精英宝典开通状态与「一键查询」(由 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)