"""斗鱼任务执行器:公共基础(由 douyu_runner.py 按功能域拆分)。""" from __future__ import annotations import asyncio import threading import time from datetime import datetime, timezone from typing import Optional from loguru import logger from sqlalchemy.orm import Session from core.douyu import DouyuActivityClient from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot from .douyu_service import DOUYU_CONFIG_FIELDS, douyu_config_value, ensure_douyu_config, douyu_task_payload # 支付/到账轮询(手册与充值共用) DOUYU_PAYMENT_POLL_SECONDS = 600 DOUYU_PAYMENT_POLL_INTERVAL = 5 class DouyuBatchRunnerCore: """斗鱼任务执行器公共基础:批次状态、日志、任务落库与客户端构造。""" def __init__( self, db: Session, batch_id: str, task_type: str, payload: Optional[dict] = None, log_queue: Optional[asyncio.Queue] = None, loop: Optional[asyncio.AbstractEventLoop] = 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 def stop(self): self._stop.set() def _push_log(self, level: str, message: str): if level == "result": try: douyu_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"[douyu] {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: Account) -> str: return account.nickname or account.username or account.uid or f"#{account.id}" @staticmethod def _to_int(value) -> int | None: if value is None: return None try: return int(value) except (TypeError, ValueError): return None @staticmethod def _format_wait_time(seconds: int | None) -> str: if seconds is None: return "" seconds = max(0, int(seconds)) days, rem = divmod(seconds, 86400) hours, rem = divmod(rem, 3600) minutes, sec = divmod(rem, 60) if days: return f"{days}天{hours}小时{minutes}分" if hours: return f"{hours}小时{minutes}分{sec}秒" return f"{minutes}分{sec}秒" def _push_task_event(self, task: DouyuTask) -> None: """向批次 WS 推送任务状态事件(level=task),前端即时更新不依赖轮询。""" if not self.log_queue or not self.loop: return try: payload = douyu_task_payload(task) except Exception: logger.exception("[douyu] 推送任务状态失败: task_id={}", task.id) return event = { "level": "task", "message": "", "task": payload, } asyncio.run_coroutine_threadsafe(self.log_queue.put(event), self.loop) def _mark_task( self, db: Session, task: DouyuTask, status: str, message: str, result: dict | None = None, ) -> None: task.status = status task.message = message[:512] if result is not None: task.result = result task.finished_at = datetime.now(timezone.utc) db.commit() self._push_task_event(task) def _update_task_progress( self, db: Session, task: DouyuTask, status: str, message: str, result: dict | None = None, ) -> None: task.status = status task.message = message[:512] if result is not None: task.result = result db.commit() self._push_task_event(task) def _upsert_goods(self, db: Session, goods: list[dict]) -> None: now = datetime.now(timezone.utc) for raw in goods: commodity_id = str(raw.get("commodityId") or raw.get("commodity_id") or "") if not commodity_id: continue row = ( db.query(DouyuGoodsSnapshot) .filter(DouyuGoodsSnapshot.commodity_id == commodity_id) .first() ) score = self._to_int(raw.get("score")) if row is None: row = DouyuGoodsSnapshot(commodity_id=commodity_id) db.add(row) row.name = str(raw.get("commodityName") or raw.get("name") or "") row.score = score row.status = str(raw.get("status") or "") row.raw = raw row.updated_at = now db.commit() def _upsert_esports_goods(self, db: Session, goods: list[dict]) -> None: now = datetime.now(timezone.utc) for raw in goods: commodity_id = str(raw.get("commodityId") or raw.get("commodity_id") or "") if not commodity_id: continue row = ( db.query(DouyuEsportsGoodsSnapshot) .filter(DouyuEsportsGoodsSnapshot.commodity_id == commodity_id) .first() ) if row is None: row = DouyuEsportsGoodsSnapshot(commodity_id=commodity_id) db.add(row) row.name = str(raw.get("commodityName") or raw.get("name") or "") row.score = self._to_int(raw.get("score")) row.status = str(raw.get("status") or "") row.raw = raw row.updated_at = now db.commit() def _upsert_xpd_goods(self, db: Session, goods: list[dict]) -> None: """同步和平小店商品快照,移除上一次热门抢购等遗留商品。""" now = datetime.now(timezone.utc) commodity_ids = { str(raw.get("commodity_id") or raw.get("iGoodsId") or "") for raw in goods } commodity_ids.discard("") query = db.query(DouyuXpdGoodsSnapshot) if commodity_ids: query.filter(~DouyuXpdGoodsSnapshot.commodity_id.in_(commodity_ids)).delete( synchronize_session=False, ) else: query.delete(synchronize_session=False) for raw in goods: commodity_id = str(raw.get("commodity_id") or raw.get("iGoodsId") or "") if not commodity_id: continue row = ( db.query(DouyuXpdGoodsSnapshot) .filter(DouyuXpdGoodsSnapshot.commodity_id == commodity_id) .first() ) if row is None: row = DouyuXpdGoodsSnapshot(commodity_id=commodity_id) db.add(row) row.name = str(raw.get("name") or raw.get("sGoodsName") or "") row.price = self._to_int(raw.get("price") or raw.get("iPrice")) row.org_price = self._to_int(raw.get("org_price") or raw.get("iOrgPrice")) row.category = str(raw.get("category") or raw.get("iCategoryId") or "") goods_left = raw.get("goods_left") if goods_left is None: goods_left = raw.get("iGoodsLeft") row.goods_left = self._to_int(goods_left) row.raw = raw row.updated_at = now db.commit() def _config_info(self, db: Session) -> dict: config = ensure_douyu_config(db) return {field: douyu_config_value(field, getattr(config, field, None)) for field in DOUYU_CONFIG_FIELDS} def _task_payload(self, task: DouyuTask) -> dict: result = task.result if isinstance(task.result, dict) else {} payload = result.get("payload") if isinstance(result.get("payload"), dict) else {} return {**payload, **self.payload} def _client(self, cookie: str) -> DouyuActivityClient: return DouyuActivityClient(cookie, logger=lambda msg: self._push_log("debug", msg)) def _sleep_interruptible(self, seconds: float) -> bool: """分段睡眠,任务停止时提前返回;返回 False 表示已被停止。""" waited = 0.0 step = 0.5 while waited < seconds: if self._stop.is_set(): return False time.sleep(min(step, seconds - waited)) waited += step return not self._stop.is_set() class DouyuBatchRegistry: """管理运行中的斗鱼任务批次。""" def __init__(self): self._batches: dict[str, dict] = {} def register(self, batch_id: str, log_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop, runner: DouyuBatchRunner): self._batches[batch_id] = { "log_queue": log_queue, "loop": loop, "runner": runner, "finished": False, "updated_at": time.time(), } def get(self, batch_id: str): return self._batches.get(batch_id) def pop(self, batch_id: str): return self._batches.pop(batch_id, None) def mark_finished(self, batch_id: str): if batch_id in self._batches: self._batches[batch_id]["finished"] = True self._batches[batch_id]["updated_at"] = time.time() def active_ids(self) -> set[str]: return { batch_id for batch_id, info in self._batches.items() if not info.get("finished") } douyu_batch_registry = DouyuBatchRegistry()