Files
live-hub-py/web/backend/services/huya_runner_core.py
T
yml2213 b5d8d5b5d8 feat(huya): 缺网页设备态的 CK 回退 HTTP cdnws 通道
问题:外部 CK(只带登录态)导入工作台后,任何任务都被「Cookie 缺少网页设备态
(sdid/udb_deviceid/udb_guiddata/udb_anobiztoken)」直接拦下,查询角色/开通都用不了。

实测结论(2026-10-15,同一张外部 CK):
- 这些字段是 WSS 通道的门禁,不是业务必需 —— 同一条 CK 走 HTTP cdnws 可以正常查询
  角色、商品,并走完整条开通链路(商品/小店/渠道/协议/返利/试算/同意/购买权限)
- A/B 对照(同一账号只抽掉 sdid):含 sdid 服务端回包,去掉即完全不回包,说明 sdid
  是 WSS 的硬门槛,而 HTTP 通道不需要
- 因此改为按能力选通道:设备态齐全走 WSS(行为不变),缺设备态回退 HTTP 并记日志

- http_client:补齐积分中心链路(get_supplier_info / accept_hy_protocol /
  calc_order_promotion / play_mall_get_my_promotion / play_mall_query_allocation /
  get_user_profile / get_follow_wechat_status);下单与支付参数对齐 2026-10-13 抓包
  (appInfo.scene=7、订单 src/orderScene=4、orderType=6、jifencenter 标记、空点击序列、
  m-shop.huya.com/points.html 回调),并去掉 orderType 1→10 的重试循环(有重复下单风险)
- huya_runner_core:_activity_client/_shop_client 不再抛 COOKIE_INCOMPLETE,改为
  返回 HTTP cdnws 客户端(方法签名与 WSS 会话保持一致,任务侧无感)

验证:tests/test_huya_http_shop_flow.py(下单/支付参数与回调域名)、
tests/test_huya_runner_transport.py(通道选择:缺设备态走 HTTP、齐全仍走 WSS);
真机 HTTP 开通链路 8 步全通(末步服务端返回业务提示「已超过该商品的购买上限」,
说明通道可用而非被丢弃)。反向验证:关掉回退即复现原报错。全量 pytest 717 passed。
2026-10-15 15:21:51 +08:00

361 lines
12 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 按功能域拆分)。"""
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 ..models import HuyaTask
from .huya_service import cookie_value
# 绑定跳转与充值下单共用(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()
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_session(uid, cookie, "activity")
self._push_log("info", f"[{uid}] 活动链路使用 WSS 会话")
return session
def _http_client(self):
"""HTTP cdnws 通道客户端(无需网页设备态,方法签名与 WSS 会话保持一致)。"""
from core.huya.http_client import HuyaHttpClient
return HuyaHttpClient(logger=lambda message: self._push_log("info", message))
@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_session(uid, cookie, "shop")
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).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()