"""虎牙任务执行器:公共基础(由 huya_runner.py 按功能域拆分)。""" from __future__ import annotations import asyncio import copy import threading import time from datetime import UTC, datetime from typing import Any from loguru import logger from sqlalchemy.orm import Session from core.douyu.proxy_fetcher import ProxyFetcher from ..models import HuyaTask from ..models import ProxyConfig as ProxyConfigModel from .huya_service import cookie_value from .proxy_service import ProxyRuntime, build_proxy_runtime # 绑定跳转与充值下单共用(bind 与 recharge 两个域都引用了 sourceId) HUYA_RECHARGE_SOURCE_ID = "yellowcarlist" from typing import TYPE_CHECKING if TYPE_CHECKING: from .huya_runner import HuyaBatchRunner class HuyaBatchRunnerCore: """虎牙任务执行器公共基础:批次状态、日志、任务落库。""" def __init__( self, db: Session, batch_id: str, task_type: str, payload: dict | None = None, log_queue: asyncio.Queue | None = None, loop: asyncio.AbstractEventLoop | None = None, concurrency: int = 3, ): self.db = db self.batch_id = batch_id self.task_type = task_type self.payload = payload or {} self.log_queue = log_queue self.loop = loop self.concurrency = max(1, min(concurrency, 10)) self._stop = threading.Event() self._counter_lock = threading.Lock() self._started = 0 self._wss_sessions = threading.local() self._proxy_cfg = None self._proxy_runtime = ProxyRuntime() self._proxy_fetcher = None self._static_proxies = None if self.task_type == "exchange_goods": self._proxy_cfg = ( db.query(ProxyConfigModel).first() if db is not None else None ) self._proxy_runtime = build_proxy_runtime( self._proxy_cfg, stop_event=self._stop, ) self._proxy_fetcher = self._create_proxy_fetcher() self._static_proxies = self._resolve_static_proxies() def stop(self): self._stop.set() def _open_wss_session(self, uid: int, cookie: str, kind: str): """在当前任务线程创建并登记 WSS 会话,统一由 finally 回收。""" from core.huya.elite_session import HuyaEliteWssSession session = HuyaEliteWssSession( uid=uid, cookie=cookie, kind=kind, logger=lambda message: self._push_log("info", f"[{uid}] {message}"), ) self._session_bucket().append(session) self._persist_session_cookie(uid, cookie, session.cookie) return session def _persist_session_cookie( self, uid: int, original_cookie: str, filled_cookie: str ): """活动会话补齐网页字段后回写账号,避免下次任务重新生成设备态。""" account_id = getattr(self._wss_sessions, "account_id", None) if not account_id: return from ..database import SessionLocal from .huya_service import persist_huya_session_cookie db = SessionLocal() try: if persist_huya_session_cookie( db, int(account_id), original_cookie, filled_cookie ): self._push_log("info", f"[{uid}] 已回写活动会话补齐后的网页 Cookie") except Exception: # noqa: BLE001 回写失败不能打断任务 logger.debug("[huya] 回写会话 Cookie 失败") finally: db.close() def _session_bucket(self) -> list[Any]: bucket = getattr(self._wss_sessions, "items", None) if bucket is None: bucket = [] self._wss_sessions.items = bucket return bucket def _activity_client(self, uid: int, cookie: str): """活动通道客户端:网页设备态齐全走 WSS,否则回退 HTTP cdnws。 2026-10-15 实测(外部 CK 导入后):只带登录态、缺 sdid/udb_deviceid/ udb_guiddata/udb_anobiztoken 的 Cookie 在 WSS 上会被服务端静默丢弃业务请求 (wsLaunch 或业务 RPC 无响应);同一条 Cookie 走 HTTP cdnws 通道可以正常 查询角色与商品。因此这里不再直接失败,而是按能力选通道。 """ missing = self._web_wss_cookie_missing(cookie) if missing: self._push_log( "info", f"[{uid}] Cookie 缺少网页设备态({', '.join(missing)}),改用 HTTP cdnws 通道", ) return self._http_client() session = self._open_wss_or_http(uid, cookie, "activity") if session is None: return self._http_client() self._push_log("info", f"[{uid}] 活动链路使用 WSS 会话") return session def _open_wss_or_http(self, uid: int, cookie: str, kind: str): """建 WSS 会话;服务端不理会握手(wsLaunch 超时)时返回 None 让调用方回退 HTTP。 2026-10-15 实测:设备态字段齐全的账号也可能被积分中心端点静默丢弃 (wsLaunch 无响应),而同一账号走 HTTP cdnws 一切正常 —— 会话建不起来时 直接回退,而不是把整批任务判失败。 """ try: return self._open_wss_session(uid, cookie, kind) except Exception as exc: # noqa: BLE001 - 任何建连/初始化异常都回退 HTTP self._push_log( "warning", f"[{uid}] {kind} WSS 会话不可用({type(exc).__name__}: {exc}),改用 HTTP cdnws 通道", ) return None def _create_proxy_fetcher(self) -> ProxyFetcher | None: """兑换任务使用现有全局代理 API,每个账号任务取一个新出口。""" return self._proxy_runtime.api_fetcher def _resolve_static_proxies(self) -> dict[str, str] | None: return self._proxy_runtime.static_proxies def _exchange_proxies(self) -> tuple[dict[str, str] | None, str, str]: """返回兑换任务的代理、来源和错误;其他任务不会调用这里。""" cfg = self._proxy_cfg if not cfg or not cfg.enabled: return None, "disabled", "" if self._static_proxies: return self._static_proxies, "static", "" if self._proxy_fetcher: try: proxy_url = self._proxy_fetcher.fetch_new_proxy(max_attempts=3) except Exception as exc: # noqa: BLE001 外部代理接口异常必须收敛到任务 return None, "error", f"获取代理失败: {exc}" if proxy_url: return ( {"http": proxy_url, "https": proxy_url}, "api", "", ) return None, "error", "代理 API 未返回可用代理" return None, "error", "已启用代理,但未配置静态代理或代理 API" def _http_client(self, proxies: dict[str, str] | None = None): """HTTP cdnws 通道客户端(无需网页设备态,方法签名与 WSS 会话保持一致)。""" from core.huya.http_client import HuyaHttpClient return HuyaHttpClient( logger=lambda message: self._push_log("info", message), proxies=proxies, ) @staticmethod def _web_wss_cookie_missing(cookie: str) -> tuple[str, ...]: """返回 WSS 前置字段中缺失的名称,不输出任何 Cookie 值。 必填集 = 认证字段 + sdid + 匿名设备态协议可签发的字段 (udb_deviceid/udb_guiddata/udb_anobiztoken,见 core/huya/anon_device.py)。 guid/_qimei_uuid42/__yamid_new/game_did 目前无服务端签发接口, 不阻断 WSS(风控风险自担)。 """ required = ( "udb_cred", "udb_biztoken", "udb_uid", "yyuid", "sdid", "udb_deviceid", "udb_guiddata", "udb_anobiztoken", ) return tuple(key for key in required if not cookie_value(cookie, key)) @classmethod def _has_web_wss_cookie(cls, cookie: str) -> bool: """9.1 活动 WSS 需要网页设备态,不接受只有 App 认证字段的 Cookie。""" return not cls._web_wss_cookie_missing(cookie) def _shop_client(self, uid: int, cookie: str): """商城通道客户端:网页设备态齐全走 WSS,否则回退 HTTP cdnws(原因同活动通道)。""" missing = self._web_wss_cookie_missing(cookie) if missing: self._push_log( "info", f"[{uid}] Cookie 缺少网页设备态({', '.join(missing)}),改用 HTTP cdnws 通道", ) return self._http_client() session = self._open_wss_or_http(uid, cookie, "shop") if session is None: return self._http_client() self._push_log("info", f"[{uid}] 商城链路使用 WSS 会话") return session def _close_wss_sessions(self): bucket = self._session_bucket() for session in reversed(bucket): try: session.close() except Exception: # noqa: BLE001 - cleanup must not mask task status logger.debug("[huya] WSS 会话清理异常") bucket.clear() def _push_log(self, level: str, message: str): if level == "result": try: huya_batch_registry.mark_finished(self.batch_id) except NameError: pass if level != "result" and message: log_func = getattr(logger, level, logger.info) log_func(f"[huya] {message}") if self.log_queue and self.loop: asyncio.run_coroutine_threadsafe( self.log_queue.put({"level": level, "message": message}), self.loop, ) @staticmethod def _account_name(account_info: dict) -> str: return ( account_info.get("nickname") or account_info.get("username") or account_info.get("uid") or f"#{account_info.get('account_id')}" ) @staticmethod def _to_int(value) -> int: text = str(value or "").strip() return int(text) if text.isdigit() else 0 def _resolve_uid(self, account_info: dict) -> int: cookie = account_info.get("cookie") or "" return ( self._to_int(account_info.get("yyuid")) or self._to_int(account_info.get("uid")) or self._to_int(cookie_value(cookie, "yyuid")) or self._to_int(cookie_value(cookie, "udb_uid")) ) @staticmethod def _format_local_time(timestamp: int) -> str: if not timestamp: return "" return ( datetime.fromtimestamp(timestamp, UTC) .astimezone() .strftime("%Y-%m-%d %H:%M:%S") ) @staticmethod def _parse_scheduled_time(value) -> datetime | None: text = str(value or "").strip() if not text: return None try: normalized = text.replace("Z", "+00:00") dt = datetime.fromisoformat(normalized) except ValueError: return None if dt.tzinfo is None: return dt.astimezone() return dt def _wait_until(self, when: datetime, uid: int) -> bool: target = when.timestamp() local_text = self._format_local_time(int(target)) self._push_log("info", f"[{uid}] 定时兑换等待到 {local_text}") while not self._stop.is_set(): remaining = target - time.time() if remaining <= 0: return True time.sleep(min(0.2, max(0.02, remaining))) return False def _mark_task( self, worker_db: Session, task: HuyaTask, status: str, message: str, result: dict | None = None, ): task.status = status task.message = message task.result = result task.finished_at = datetime.now(UTC) worker_db.commit() self._push_task_event(task) def _update_task_progress( self, worker_db: Session, task: HuyaTask, status: str, message: str, result: dict | None = None, ): task.status = status task.message = message if result is not None: task.result = result worker_db.commit() self._push_task_event(task) def _push_task_event(self, task: HuyaTask) -> None: """向批次 WS 推送任务状态;二维码图片留给详情接口按需读取。""" if not self.log_queue or not self.loop: return result = copy.deepcopy(task.result) if isinstance(task.result, dict) else None if result and isinstance(result.get("mini_qrcode_image"), str): result.pop("mini_qrcode_image", None) result["has_mini_qrcode"] = True event = { "level": "task", "message": "", "task": { "id": task.id, "batch_id": task.batch_id, "account_id": task.account_id, "task_type": task.task_type, "handbook_scope": getattr(task, "handbook_scope", "legacy") or "legacy", "status": task.status or "", "message": task.message or "", "result": result, "created_by": task.created_by, "created_at": task.created_at.isoformat() if task.created_at else None, "finished_at": task.finished_at.isoformat() if task.finished_at else None, }, } asyncio.run_coroutine_threadsafe(self.log_queue.put(event), self.loop) class HuyaBatchRegistry: """管理运行中的虎牙批次。""" def __init__(self): self._batches: dict[str, dict] = {} self._lock = threading.Lock() def _cleanup_locked(self, ttl_seconds: int = 300): now = time.time() expired = [ batch_id for batch_id, batch in self._batches.items() if batch.get("finished") and now - float(batch.get("finished_at") or now) > ttl_seconds ] for batch_id in expired: self._batches.pop(batch_id, None) def register( self, batch_id: str, log_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop, runner: HuyaBatchRunner, ): with self._lock: self._cleanup_locked() self._batches[batch_id] = { "log_queue": log_queue, "loop": loop, "runner": runner, "finished": False, "finished_at": None, } def get(self, batch_id: str): with self._lock: self._cleanup_locked() return self._batches.get(batch_id) def active_ids(self) -> set[str]: with self._lock: self._cleanup_locked() return { batch_id for batch_id, batch in self._batches.items() if not batch.get("finished") } def mark_finished(self, batch_id: str): with self._lock: batch = self._batches.get(batch_id) if not batch: return batch["finished"] = True batch["finished_at"] = time.time() def pop(self, batch_id: str): with self._lock: return self._batches.pop(batch_id, None) huya_batch_registry = HuyaBatchRegistry()