"""斗鱼活动任务批次执行器。""" from __future__ import annotations import asyncio import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone from typing import Optional from loguru import logger from sqlalchemy.orm import Session, joinedload from core.douyu import DouyuActivityClient, DouyuActivityError from ..database import SessionLocal from ..models import Account, DouyuGoodsSnapshot, DouyuTask from .douyu_service import ( DOUYU_CONFIG_FIELDS, account_uid, douyu_config_value, ensure_douyu_config, latest_success_cookie, update_account_profile_from_cookie, ) class DouyuBatchRunner: """批量执行斗鱼活动任务,通过队列推送实时日志。""" 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 _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() 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 _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 _execute_refresh_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) result = client.list_goods(manual_id=config["manual_id"], rid=config["rid"]) goods = result["goods"] self._upsert_goods(db, goods) account.bind_status = account.bind_status or "active" account.updated_at = datetime.now(timezone.utc) self._mark_task(db, task, "success", f"已刷新商品 {len(goods)} 个", {"goods_count": len(goods), "goods": goods}) def _execute_get_bind_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) result = client.get_bind_qr(str(config["bind_act_alias"])) account.bind_status = "bind_qr_generated" account.updated_at = datetime.now(timezone.utc) self._mark_task(db, task, "success", "绑定二维码已生成", result) def _execute_confirm_bind(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) before = client.bind_info(str(config["legacy_act_alias"]), v2=True) result = client.confirm_bind(str(config["confirm_act_alias"])) after = client.bind_info(str(config["confirm_act_alias"]), v2=False) role_name = after.get("role_name") or before.get("role_name") or "" account.game_name = role_name or account.game_name account.game_channel = " / ".join(part for part in [after.get("area_name"), after.get("plat_name")] if part) account.bind_status = "bind_confirmed" account.updated_at = datetime.now(timezone.utc) self._mark_task(db, task, "success", f"绑定成功: {role_name or '已确认'}", {"before": before, "confirm": result, "bind_info": after}) def _execute_create_elite_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) ctn = str(self._task_payload(task).get("ctn") or "") if not ctn: ctn = client.acf_ccn(refresh_subscribe=True) result = client.create_elite_qr( ctn=ctn, act_alias=str(config["confirm_act_alias"]), amount=int(config["elite_amount"]), room_id=str(config["room_id"]), ) account.bind_status = "elite_qr_created" account.updated_at = datetime.now(timezone.utc) self._mark_task(db, task, "success", "精英宝典支付码已生成", result) def _execute_create_gold_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): payload = self._task_payload(task) amount = int(payload.get("amount") or payload.get("gold_amount") or 1) client = self._client(cookie) result = client.create_gold_qr(amount=amount, pay_type=int(config["gold_pay_type"])) account.bind_status = "gold_qr_created" account.updated_at = datetime.now(timezone.utc) self._mark_task(db, task, "success", f"鱼翅 {amount} 元支付码已生成", result) def _execute_donate_elite_gift(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): payload = self._task_payload(task) gift_count = int(payload.get("gift_count") or payload.get("count") or 1) client = self._client(cookie) result = client.donate_elite_gift( gift_count=gift_count, room_id=str(payload.get("room_id") or config["room_id"]), gift_id=str(payload.get("gift_id") or config["gift_id"]), skin_id=str(payload.get("skin_id") or config["skin_id"]), ) account.bind_status = "gift_donated" account.updated_at = datetime.now(timezone.utc) self._mark_task(db, task, "success", f"赠送精英令成功: {gift_count}", result) def _execute_query_points(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) ctn = client.acf_ccn(refresh_subscribe=False) uid = account_uid(account, cookie) if not uid: self._mark_task(db, task, "failed", "Cookie 中没有 acf_uid,无法查询积分") return result = client.query_points(uid=uid, ctn=ctn) points = self._to_int(result.get("points")) account.uid = uid account.points = points update_account_profile_from_cookie(account, cookie) account.bind_status = "points_queried" account.updated_at = datetime.now(timezone.utc) self._mark_task(db, task, "success", f"积分: {points if points is not None else '-'}", result) def _execute_exchange_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): import time as time_mod payload = self._task_payload(task) commodity_id = str(payload.get("commodity_id") or payload.get("commodityId") or "").strip() if not commodity_id: self._mark_task(db, task, "failed", "请选择兑换商品") return client = self._client(cookie) ctn = client.acf_ccn(refresh_subscribe=False) result = None last_error = "" for attempt in range(8 + 1): if self._stop.is_set(): self._mark_task(db, task, "stopped", "任务已停止") return try: result = client.exchange_goods( manual_id=str(config["manual_id"]), rid=str(config["rid"]), commodity_id=commodity_id, ctn=ctn, ) break except DouyuActivityError as exc: last_error = str(exc) if attempt >= 8: self._mark_task(db, task, "failed", f"兑换失败(已重试{attempt}次): {last_error}") return error_lower = last_error.lower() if any(kw in error_lower for kw in ("无效", "太快", "csrf")): self._push_log("info", f" 重试 {attempt + 1}/8: {last_error},刷新 csrf_token...") try: token = client.csrf_token() self._push_log("debug", f" csrf_token 已刷新: {token[:12]}...") except Exception: pass else: self._push_log("info", f" 重试 {attempt + 1}/8: {last_error}") time_mod.sleep(0.3) if result is None: self._mark_task(db, task, "failed", f"兑换失败: {last_error}") return goods = ( db.query(DouyuGoodsSnapshot) .filter(DouyuGoodsSnapshot.commodity_id == commodity_id) .first() ) account.bind_status = "goods_exchanged" account.updated_at = datetime.now(timezone.utc) self._mark_task( db, task, "success", f"兑换成功: {(goods.name if goods else '') or commodity_id}", {"goods": goods.raw if goods else None, **result}, ) def _execute_query_game_name(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) result = client.bind_info(str(config["confirm_act_alias"]), v2=False) if not result.get("role_name"): result = client.bind_info(str(config["legacy_act_alias"]), v2=True) role_name = result.get("role_name") or "" account.game_name = role_name account.game_channel = " / ".join(part for part in [result.get("area_name"), result.get("plat_name")] if part) account.bind_status = "game_queried" if role_name else "game_not_bound" account.change_role_wait_time = self._to_int(result.get("change_role_wait_time")) account.updated_at = datetime.now(timezone.utc) message = f"游戏名: {role_name}" if role_name else "未获取到游戏名" self._mark_task(db, task, "success" if role_name else "failed", message, result) def _execute_query_change_bind_time(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) result = client.bind_info(str(config["confirm_act_alias"]), v2=True) wait_time = self._to_int(result.get("change_role_wait_time")) account.change_role_wait_time = wait_time account.bind_status = "change_time_queried" account.updated_at = datetime.now(timezone.utc) result["change_role_wait_text"] = self._format_wait_time(wait_time) self._mark_task(db, task, "success", f"换绑剩余: {result['change_role_wait_text'] or '-'}", result) def _execute_query_limited_goods(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) result = client.query_limited_goods(manual_id=str(config["manual_id"]), rid=str(config["rid"])) limited = result["limited_goods"] names = [str(item.get("commodityName") or "") for item in limited if item.get("commodityName")] message = "无限制商品" if not names else f"限兑 {len(names)} 个: {', '.join(names[:5])}" account.bind_status = "limited_goods_queried" account.updated_at = datetime.now(timezone.utc) self._mark_task(db, task, "success", message, {"limited_count": len(limited), "limited_goods": limited}) def _execute_query_gold_balance(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) gold = client.gold_account() exchange = client.exchange_balance() account.gold_balance = self._to_int(gold.get("gold")) account.exchange_balance = self._to_int(exchange.get("count")) account.bind_status = "gold_balance_queried" account.updated_at = datetime.now(timezone.utc) self._mark_task( db, task, "success", f"鱼翅余额: {account.gold_balance if account.gold_balance is not None else '-'}", {"gold": gold, "exchange_balance": exchange}, ) def _execute_query_exchange_records(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) result = client.exchange_records(manual_id=str(config["manual_id"])) records = result["records"] account.bind_status = "exchange_records_queried" account.updated_at = datetime.now(timezone.utc) self._mark_task(db, task, "success", f"兑换记录 {len(records)} 条" if records else "暂无兑换记录", result) def _execute_prefetch_csrf_token(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) token = client.csrf_token() account.bind_status = "csrf_token_ready" account.updated_at = datetime.now(timezone.utc) self._mark_task(db, task, "success", "获取 csrf_token 成功", {"csrf_token": token, "cookie": client.cookie}) def _execute_one(self, task_id: int, config: dict, total: int): worker_db = SessionLocal() try: task = ( worker_db.query(DouyuTask) .options(joinedload(DouyuTask.account)) .filter(DouyuTask.id == task_id) .first() ) if not task or self._stop.is_set(): return account = task.account task.status = "running" task.message = "执行中" worker_db.commit() with self._counter_lock: self._started += 1 current = self._started self._push_log("info", f"[{current}/{total}] 开始: {self._account_name(account)}") cookie = latest_success_cookie(worker_db, account.id) if not cookie: self._mark_task(worker_db, task, "failed", "账号没有成功登录 Cookie") self._push_log("warning", f"[{current}] {self._account_name(account)} 无 Cookie") return update_account_profile_from_cookie(account, cookie) handler = { "refresh_goods": self._execute_refresh_goods, "get_bind_qr": self._execute_get_bind_qr, "confirm_bind": self._execute_confirm_bind, "create_elite_qr": self._execute_create_elite_qr, "create_gold_qr": self._execute_create_gold_qr, "donate_elite_gift": self._execute_donate_elite_gift, "query_points": self._execute_query_points, "exchange_goods": self._execute_exchange_goods, "query_game_name": self._execute_query_game_name, "query_change_bind_time": self._execute_query_change_bind_time, "query_limited_goods": self._execute_query_limited_goods, "query_gold_balance": self._execute_query_gold_balance, "query_exchange_records": self._execute_query_exchange_records, "prefetch_csrf_token": self._execute_prefetch_csrf_token, }.get(task.task_type) if handler is None: self._mark_task(worker_db, task, "failed", "不支持的任务类型") return handler(worker_db, task, account, cookie, config) self._push_log("success", f"[{current}] {self._account_name(account)} {task.message}") except DouyuActivityError as exc: if "task" in locals() and task: self._mark_task(worker_db, task, "failed", str(exc)) self._push_log("warning", f"斗鱼任务失败: {exc}") except Exception as exc: if "task" in locals() and task: self._mark_task(worker_db, task, "error", str(exc)) self._push_log("error", f"斗鱼任务异常: {exc}") finally: worker_db.close() def run(self): """执行批次任务。""" self._push_log("info", f"斗鱼任务批次 {self.batch_id} 开始") try: config = self._config_info(self.db) tasks = ( self.db.query(DouyuTask) .filter(DouyuTask.batch_id == self.batch_id, DouyuTask.status == "planned") .order_by(DouyuTask.id.asc()) .all() ) if not tasks: self._push_log("warning", "没有可执行的斗鱼任务") self._push_log("result", "") return for task in tasks: task.status = "pending" task.message = "等待执行" self.db.commit() total = len(tasks) with ThreadPoolExecutor(max_workers=self.concurrency) as executor: futures = [] for task in tasks: if self._stop.is_set(): break futures.append(executor.submit(self._execute_one, task.id, config, total)) for future in as_completed(futures): try: future.result() except Exception as exc: self._push_log("error", f"Worker 异常: {exc}") if self._stop.is_set(): self._push_log("warning", f"斗鱼任务批次 {self.batch_id} 已停止") else: self._push_log("info", f"斗鱼任务批次 {self.batch_id} 完成") self._push_log("result", "") finally: self.db.close() 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()