"""虎牙任务执行器:积分与商城(由 huya_runner.py 按功能域拆分)。""" 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, HuyaGoodsSnapshot, HuyaTask from .huya_activity_query import query_huya_activity from .huya_pay_link import pay_qr_path, resolve_pay_targets from .huya_points_center import ( fetch_points_center_catalog, points_center_options_from_config, ) from .huya_points_exchange import ( EXCHANGE_STATUS_EXCHANGED, EXCHANGE_STATUS_PAYMENT_REQUIRED, complete_exchange_after_payment, normalize_spu_id, prepare_exchange, ) # 积分查询超时:HTTP 通道正常几百毫秒,10 秒足够;失败时别让整批任务长时间挂住。 HUYA_POINTS_QUERY_TIMEOUT_SECONDS = 10.0 class GoodsMixin: """积分与商城域:积分/兑换记录/商品刷新/兑换。""" 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 _push_log(self, level: str, message: str) -> None: ... def _activity_client(self, uid: int, cookie: str): ... def _shop_client(self, uid: int, cookie: str): ... def _http_client(self, proxies: dict[str, str] | None = None): ... def _exchange_proxies( self, ) -> tuple[dict[str, str] | None, str, str]: ... def _probe_bind_status( self, uid: int, cookie: str, b_act_id_int: int, outer_act_id: str = "" ): ... def _mark_task(self, *args: Any, **kwargs: Any) -> None: ... def _update_task_progress(self, *args: Any, **kwargs: Any) -> None: ... def _wait_recharge_payment(self, *args: Any, **kwargs: Any): ... def _wait_until(self, when: datetime, uid: int) -> bool: ... def _parse_scheduled_time(self, value: Any) -> datetime | None: ... def _resolve_live_exchange_goods( self, client: Any, *, uid: int, cookie: str, config_info: dict, submitted_product_id: str, product_name: str, ) -> tuple[dict | None, str]: """用实时积分中心商品把旧 prize_id 或过期选择解析成当前 SPU。 2026-11-13 两份最新 HAR 确认:页面商品列表使用 queryAllocation 返回的 ``hy-*`` SPU;旧快照里的数字 prize_id 不能直接用于兑换。 """ catalog = fetch_points_center_catalog( uid, cookie, client=client, **points_center_options_from_config(config_info), ) goods, message = catalog.goods, catalog.message if not goods: return None, message or "实时商品列表为空" wanted_name = product_name.strip() wanted_id = normalize_spu_id(submitted_product_id) if wanted_name: by_name = [ item for item in goods if str(item.get("name") or "").strip() == wanted_name ] if len(by_name) == 1: return by_name[0], "" if len(by_name) > 1 and wanted_id: for item in by_name: if normalize_spu_id(item.get("product_id")) == wanted_id: return item, "" if wanted_id: for item in goods: if normalize_spu_id(item.get("product_id")) == wanted_id: return item, "" return ( None, "商品列表已更新,当前实时列表中找不到所选商品,请重新选择", ) def _execute_query_act_tasks( self, worker_db: Session, task: HuyaTask, account: HuyaAccount, account_info: dict, config_info: dict, ): """读取精英宝典任务详情,供专用工作台展示购买/观看任务。""" uid = self._resolve_uid(account_info) cookie = account_info.get("cookie") or "" if not uid or not cookie: self._mark_task(worker_db, task, "failed", "账号 UID 或 Cookie 为空") return act_id = self._to_int(self.payload.get("act_id") or 25135) if not act_id: self._mark_task(worker_db, task, "failed", "精英宝典活动 ID 无效") return client: Any = self._activity_client(uid, cookie) response = client.get_act_task_detail(uid=uid, cookie=cookie, act_id=act_id) if response is None: self._mark_task(worker_db, task, "error", "虎牙活动任务接口无响应") return result = response.to_dict() result["act_id"] = act_id act_info = client.get_act_info(act_id=act_id) user_tasks = client.get_act_user_task_detail( uid=uid, cookie=cookie, act_id=act_id ) if act_info is not None: result["act_info"] = act_info.to_dict() if user_tasks is not None: result["user_task_detail"] = user_tasks.to_dict() if response.status != 200: self._mark_task( worker_db, task, "failed", response.msg or f"虎牙活动任务查询失败: {response.status}", result, ) return account.status = "tasks_queried" account.updated_at = datetime.now(UTC) self._mark_task( worker_db, task, "success", f"已读取 {len(result.get('tasks', []))} 项精英宝典任务", result, ) def _execute_query_points( self, worker_db: Session, task: HuyaTask, account: HuyaAccount, account_info: dict, config_info: dict, ): """查询积分:先验 CK,再走积分中心 HTTP 接口。 2026-10-15 真机实测:旧活动接口 ``webActUI.getUserScore`` 对任何 sid 都返回 603「活动已结束」(该接口已下线,与配置无关),不再作为回退——否则积分中心 超时后还要再等一次 15 秒。积分中心接口返回 ``tag2``=当前积分、 ``tag3``=兑换门槛积分,另可顺手取 GameBizUI 的成长值/成长等级。 CK 判定复用外部单协作的新探针(HTTP + outer_act_id + 15 秒):积分中心对 失效 CK 仍可能返回 200/0 分,不能拿积分结果判断凭据是否有效。 """ 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 # ---- 先验 CK:没有回包就立即判失效,不再空跑积分与旧接口 ---- ck_valid = False protocol = self._bind_protocol(config_info, self.payload) b_act_id_int = protocol["b_act_id"] if protocol["bind_gid"] or b_act_id_int: bind_status, _bind_query_result = self._probe_bind_status( uid=uid, cookie=cookie, b_act_id_int=b_act_id_int, outer_act_id=protocol["outer_act_id"], gid=protocol["gid"], is_use_outer_act_id=protocol["is_use_outer_act_id"], scene=protocol["scene"], ) if bind_status is None: account.status = "ck_expired" account.updated_at = datetime.now(UTC) self._mark_task( worker_db, task, "error", "虎牙角色信息接口无响应(CK 已失效,请重新登录或重新导入 Cookie)", { "ck_expired": True, "ck_expired_reason": "角色信息接口无响应", }, ) return ck_valid = int(getattr(bind_status, "status", 0) or 0) == 200 goods_num = ( self._to_int(self.payload.get("goods_num")) or POINTS_REDEEM_GOODS_NUM ) # 直接走 HTTP:积分中心不需要 WSS 会话,省掉每个账号建连/初始化的几秒等待。 client: Any = self._http_client() redeem = client.get_redeem_need_goods( uid=uid, guid="", cookie=cookie, goods_num=goods_num, timeout=HUYA_POINTS_QUERY_TIMEOUT_SECONDS, ) if redeem is None: offline_result: dict[str, Any] = {"goods_num": goods_num} if ck_valid: offline_result["ck_valid"] = True self._mark_task( worker_db, task, "error", "虎牙积分中心接口无响应(CK 探针正常,请稍后重试)" if ck_valid else "虎牙积分中心接口无响应", offline_result, ) return result = redeem.to_dict() result["goods_num"] = goods_num if redeem.code != 200: self._mark_task( worker_db, task, "failed", redeem.message or f"虎牙积分查询失败: {redeem.code}", result, ) return points = self._to_int(redeem.value("tag2")) result["points"] = points result["redeem_threshold"] = self._to_int(redeem.value("tag3")) growth = client.get_grow_level_user_info( uid=uid, guid="", cookie=cookie, timeout=HUYA_POINTS_QUERY_TIMEOUT_SECONDS, ) if growth is not None and growth.code == 0: result["grow_value"] = self._to_int(growth.value("tag1", "tag0")) result["grow_level"] = self._to_int(growth.value("tag1", "tag1")) result["nickname"] = growth.value("tag2") or "" account.points = points account.status = "points_queried" account.updated_at = datetime.now(UTC) self._mark_task(worker_db, task, "success", f"积分: {points}", result) def _execute_query_exchange_records( 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 client: Any = self._http_client() activity = query_huya_activity( client, uid=uid, cookie=cookie, config_info=config_info, configured_sid=self._to_int( self.payload.get("sid") or config_info.get("sid") ), want_records=True, ) sid_int = activity.sid configured_sid = activity.configured_sid sid_source = activity.sid_source if activity.sid_error and sid_int: self._push_log( "warning", f"[{uid}] 自动发现兑换活动 SID 失败,回退配置值: {activity.sid_error}", ) if not sid_int: self._mark_task( worker_db, task, "failed", activity.errors[-1] if activity.errors else "无法确定当前兑换活动 SID,请先刷新积分中心商品", ) return response = activity.records_response if response is None: self._mark_task( worker_db, task, "error", activity.errors[-1] if activity.errors else "虎牙兑换记录接口无响应", ) return result = response.to_dict() result["sid"] = sid_int result["sid_source"] = sid_source result["configured_sid"] = configured_sid result["account_snapshot"] = { "nickname": ( getattr(account, "nickname", "") or getattr(task, "account_nickname", "") or "" ), "uid": str(getattr(account, "uid", "") or account_info.get("uid") or uid), "game_name": getattr(account, "game_name", "") or "", "game_channel": getattr(account, "game_channel", "") or "", "game_phone": getattr(account, "game_phone", "") or "", } if response.status != 200: self._mark_task( worker_db, task, "failed", response.msg or f"虎牙兑换记录查询失败: {response.status}", result, ) return records = result.get("records", []) for index, item in enumerate(records, start=1): item["index"] = index item["exchange_time_text"] = self._format_local_time( int(item.get("exchange_time") or 0) ) if item.get("score") is not None: item["score_text"] = f"{int(item.get('score') or 0)}积分" account.status = "exchange_records_queried" account.updated_at = datetime.now(UTC) count = len(records) message = f"兑换记录 {count} 条" if count else "暂无兑换记录" self._mark_task(worker_db, task, "success", message, result) def _execute_refresh_goods( 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 sid_int = self._to_int(config_info.get("sid")) or 2203 query_options = points_center_options_from_config(config_info) catalog = fetch_points_center_catalog( uid, cookie, client=self._http_client(), **query_options, ) goods, message = catalog.goods, catalog.message now = datetime.now(UTC) worker_db.query(HuyaGoodsSnapshot).filter( HuyaGoodsSnapshot.account_id == account.id, HuyaGoodsSnapshot.sid == sid_int, ).delete(synchronize_session=False) for item in goods: worker_db.add( HuyaGoodsSnapshot( account_id=account.id, sid=sid_int, product_id=item["product_id"], name=item["name"], price=item["price"], remain_text=item["remain_text"], raw=item, updated_at=now, ) ) result = { "source": "PlayMallUI.queryAllocation", "sid": sid_int, "page_code": query_options["page_code"], "source_id": query_options["source_id"], "scene": query_options["scene"], "goods": goods, } if not goods: self._mark_task(worker_db, task, "failed", message, result) return account.status = "goods_refreshed" account.updated_at = now self._mark_task( worker_db, task, "success", f"已刷新积分中心商品 {len(goods)} 个", result, ) def _execute_exchange_goods( self, worker_db: Session, task: HuyaTask, account: HuyaAccount, account_info: dict, config_info: dict, ): submitted_product_id = normalize_spu_id(self.payload.get("product_id")) requested_product_name = str(self.payload.get("product_name") or "").strip() if not submitted_product_id and not requested_product_name: self._mark_task(worker_db, task, "failed", "请选择兑换商品") return 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 scheduled_at = self._parse_scheduled_time(self.payload.get("scheduled_at")) if self.payload.get("scheduled_at") and scheduled_at is None: self._mark_task(worker_db, task, "failed", "定时兑换时间格式无效") return if ( scheduled_at and scheduled_at.timestamp() > time.time() and not self._wait_until(scheduled_at, uid) ): self._mark_task(worker_db, task, "stopped", "兑换任务已停止") return pid = self._to_int(config_info.get("room_pid")) if not pid: self._mark_task(worker_db, task, "failed", "请先配置虎牙直播间 ID") return pay_channel = self._normalize_pay_channel( self.payload.get("pay_channel") or config_info.get("pay_channel") ) exchange_use_proxy = str( config_info.get("exchange_use_proxy") or "0" ).strip().lower() in {"1", "true", "on", "yes"} if exchange_use_proxy: proxies, proxy_source, proxy_error = self._exchange_proxies() else: proxies, proxy_source, proxy_error = None, "disabled", "" if proxy_error: self._mark_task( worker_db, task, "failed", f"兑换代理不可用:{proxy_error}", {"proxy_used": False, "proxy_source": "error"}, ) return if proxies: self._push_log("info", f"[{uid}] 兑换任务使用代理: {proxy_source}") elif exchange_use_proxy: self._push_log("info", f"[{uid}] 兑换任务未使用代理: {proxy_source}") else: self._push_log("info", f"[{uid}] 兑换任务直连(兑换代理开关关闭)") client: Any = self._http_client(proxies=proxies) live_goods, live_goods_error = self._resolve_live_exchange_goods( client, uid=uid, cookie=cookie, config_info=config_info, submitted_product_id=submitted_product_id, product_name=requested_product_name, ) if live_goods is None: self._mark_task( worker_db, task, "failed", f"实时商品校验失败:{live_goods_error}", { "submitted_product_id": submitted_product_id, "product_name": requested_product_name, "proxy_used": bool(proxies), "proxy_source": proxy_source, }, ) return spu_id = normalize_spu_id(live_goods.get("product_id")) product_name = str(live_goods.get("name") or requested_product_name or spu_id) prepared = prepare_exchange( client, uid=uid, cookie=cookie, pid=pid, spu_id=spu_id, pay_channel=pay_channel, stopped=self._stop.is_set, log=lambda message: self._push_log("info", message), ) common = { "spu_id": spu_id, "submitted_product_id": submitted_product_id, "resolved_product_id": spu_id, "product_name": (prepared.target.get("name") if prepared.target else "") or product_name, "scheduled_at": scheduled_at.isoformat() if scheduled_at else "", "executed_at": datetime.now(UTC).isoformat(), "redeem_state": prepared.state.to_dict() if prepared.state else None, "target": prepared.target, "exchange_plan": prepared.plan, "proxy_used": bool(proxies), "proxy_source": proxy_source, } if prepared.status == EXCHANGE_STATUS_EXCHANGED: result = { **common, "exchange_mode": "points_only", "exchange_order": prepared.order, "payment_status": "not_required", "payment_status_label": "积分直兑", } account.status = "goods_exchanged" account.updated_at = datetime.now(UTC) if prepared.state: account.points = prepared.state.user_score self._mark_task(worker_db, task, "success", prepared.message, result) return if prepared.status != EXCHANGE_STATUS_PAYMENT_REQUIRED or not prepared.purchase: self._mark_task( worker_db, task, "failed", prepared.message, {**common, **prepared.detail}, ) return purchase = prepared.purchase pay_targets = resolve_pay_targets(purchase.pay_url) result = { **common, "exchange_mode": "buy_points_then_exchange", "points_package": prepared.plan, "points_purchase": { "order_id": purchase.order_id, "app_order_id": purchase.app_order_id, "pay_order_id": purchase.pay_order_id, "product_name": purchase.product_name, "count": purchase.count, "unit_price": purchase.unit_price, "amount": purchase.amount, "supplier_name": purchase.supplier_name, "order": purchase.order_raw, "pay": purchase.pay_raw, }, "pay_url": purchase.pay_url, "pay_qr_code": pay_targets.qr_code, "pay_cashier_url": pay_targets.cashier_url, "pay_qr_path": pay_qr_path(task.id) if task is not None else "", "payment_status": "pending", "payment_status_label": "等待支付积分包", "payment_paid": False, } account.status = "exchange_payment_pending" account.updated_at = datetime.now(UTC) self._update_task_progress( worker_db, task, "running", f"积分不足,需先购买 {prepared.plan['name'] if prepared.plan else '积分包'},等待扫码支付", result, ) payment_status, _payment_order = self._wait_recharge_payment( client=client, uid=uid, guid="", cookie=cookie, order_id=purchase.order_id, sid=self._to_int(config_info.get("sid")) or 2203, result=result, ) if payment_status == "paid": completed = complete_exchange_after_payment( client, uid=uid, cookie=cookie, pid=pid, spu_id=spu_id, ) result.update( { "post_payment_redeem_state": ( completed.state.to_dict() if completed.state else None ), "exchange_order": completed.order, "target": completed.target or prepared.target, } ) if completed.status == EXCHANGE_STATUS_EXCHANGED: account.status = "goods_exchanged" account.updated_at = datetime.now(UTC) if completed.state: account.points = completed.state.user_score self._mark_task( worker_db, task, "success", f"积分包支付成功,{completed.message}", result, ) else: self._mark_task( worker_db, task, "failed", f"积分包支付成功,但兑换失败:{completed.message}", result, ) return if payment_status == "stopped": self._mark_task( worker_db, task, "stopped", "积分包支付监听已停止", result, ) return self._mark_task( worker_db, task, "timeout", f"积分包支付超时,订单 #{purchase.order_id} 仍未完成", result, )