"""斗鱼活动任务批次执行器。""" from __future__ import annotations import asyncio import re import threading import time from decimal import Decimal 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, FishFinRechargeClient, FishFinRechargeConfig, FishFinRechargeError, ) from ..database import SessionLocal from ..models import Account, DouyuEsportsGoodsSnapshot, DouyuGoodsSnapshot, DouyuTask, DouyuXpdGoodsSnapshot from .douyu_service import ( DOUYU_CONFIG_FIELDS, account_uid, douyu_config_value, ensure_douyu_config, latest_success_cookie, douyu_task_payload, update_account_profile_from_cookie, ) DOUYU_LEGACY_BIND_ACT_ALIAS = "20250213NQCYX" DOUYU_BIND_ROLE_POLL_SECONDS = 65 DOUYU_BIND_ROLE_POLL_INTERVAL = 5 DOUYU_XPD_BIND_POLL_SECONDS = 300 DOUYU_XPD_BIND_POLL_INTERVAL = 5 DOUYU_PAYMENT_POLL_SECONDS = 600 DOUYU_PAYMENT_POLL_INTERVAL = 5 DOUYU_GIFT_POINTS_REFRESH_TIMES = 3 DOUYU_GIFT_POINTS_REFRESH_INTERVAL = 2 DOUYU_CONFIRM_EFFECT_POLL_TIMES = 3 DOUYU_CONFIRM_EFFECT_POLL_INTERVAL = 3 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}秒" @staticmethod def _action_act_alias(config: dict, key: str) -> str: """动作类接口用的活动 alias,排除只用于查询最新角色的 legacy alias。""" alias = str(config.get(key) or "").strip() query_only_alias = str(config.get("legacy_act_alias") or "").strip() if not alias: return "" if alias in {query_only_alias, DOUYU_LEGACY_BIND_ACT_ALIAS}: return "" return alias @classmethod def _bind_qr_act_alias(cls, config: dict) -> str: """生成绑定二维码用的活动 alias。""" return cls._action_act_alias(config, "bind_act_alias") or cls._action_act_alias(config, "confirm_act_alias") @staticmethod def _query_bind_act_aliases(config: dict) -> list[str]: """查询/轮询角色用的 alias 列表。 现网最新绑定信息在 legacy(cjm);活动 alias 可能仍用于扫码/确认, 所以按优先级去重返回多个,轮询时取“更像新扫码结果”的那个。 """ ordered = [ str(config.get("legacy_act_alias") or "").strip(), str(config.get("confirm_act_alias") or "").strip(), str(config.get("bind_act_alias") or "").strip(), ] aliases: list[str] = [] for alias in ordered: if alias and alias not in aliases: aliases.append(alias) return aliases @classmethod def _confirm_act_alias(cls, config: dict) -> str: """确认绑定接口用的活动 alias。""" return cls._action_act_alias(config, "confirm_act_alias") or cls._action_act_alias(config, "bind_act_alias") # 兼容旧调用名 @classmethod def _current_bind_act_alias(cls, config: dict) -> str: return cls._confirm_act_alias(config) or cls._bind_qr_act_alias(config) @classmethod def _action_act_aliases(cls, config: dict) -> list[str]: """当前活动动作 alias;不包含只用于查询最新扫码态的 legacy/cjm。""" aliases: list[str] = [] for key in ("confirm_act_alias", "bind_act_alias"): alias = cls._action_act_alias(config, key) if alias and alias not in aliases: aliases.append(alias) return aliases @staticmethod def _role_channel(bind_info: dict) -> str: return " / ".join( part for part in [bind_info.get("area_name"), bind_info.get("plat_name")] if part ) @staticmethod def _is_truthy_flag(value) -> bool: if value is True: return True if value is False or value is None: return False text = str(value).strip().lower() return text in {"1", "true", "yes", "y"} @classmethod def _is_bound_act(cls, bind_info: dict | None) -> bool: if not bind_info: return False return cls._is_truthy_flag(bind_info.get("is_bound_act")) @classmethod def _can_change_role(cls, bind_info: dict | None) -> bool: """综合 can_change_role 与换绑倒计时判断是否允许换绑。""" if not bind_info: return True if str(bind_info.get("api_version") or "") == "esports": can_change_time = cls._to_int(bind_info.get("can_change_time")) if can_change_time is not None: return can_change_time <= int(datetime.now(timezone.utc).timestamp()) wait_time = cls._to_int(bind_info.get("change_role_wait_time")) if wait_time is not None and wait_time > 0: return False if bind_info.get("can_change_role") is not None: return cls._is_truthy_flag(bind_info.get("can_change_role")) # 已绑定但接口没给倒计时/开关时,默认允许(避免误杀首次绑定) return True @classmethod def _is_change_cooling(cls, bind_info: dict | None) -> bool: """是否处于换绑冷却:已有绑定角色且当前不可换绑。""" if not bind_info: return False role_name = str(bind_info.get("role_name") or "").strip() if not role_name or not cls._is_bound_act(bind_info): return False return not cls._can_change_role(bind_info) @classmethod def _is_pending_role( cls, bind_info: dict | None, *, baseline_role_name: str = "", baseline_is_bound_act: bool = False, ) -> bool: """判断是否出现了可确认的扫码角色。 规则: 1. 必须有角色名 2. 已绑定同一角色(或无 baseline 的已绑定)不算 pending 3. 角色名相对 baseline 变化,或从已绑定变成待确认,算 pending 4. need_bind_act / need_bind_role 且角色相对 baseline 有变化,算 pending 5. 无 baseline 且未绑定但有角色,视为待确认残留 """ if not bind_info: return False role_name = str(bind_info.get("role_name") or "").strip() if not role_name: return False is_bound_act = cls._is_bound_act(bind_info) need_bind_act = cls._is_truthy_flag(bind_info.get("need_bind_act")) need_bind_role = cls._is_truthy_flag(bind_info.get("need_bind_role")) role_changed = bool(baseline_role_name) and role_name != baseline_role_name if is_bound_act: # 已绑定:仅当相对 baseline 角色发生变化时才视为新扫码结果 return role_changed if need_bind_act or need_bind_role: if not baseline_role_name: return True return role_changed or baseline_is_bound_act if not baseline_role_name: return True if role_changed: return True # 同一角色从已绑定变为未绑定 return baseline_is_bound_act def _bind_snapshot(self, bind_info: dict | None) -> dict: info = bind_info or {} role_name = str(info.get("role_name") or "") return { "role_name": role_name, "area_name": info.get("area_name") or "", "plat_name": info.get("plat_name") or "", "nickname": info.get("nickname") or "", "is_bound_act": self._is_bound_act(info), "is_bound_role": self._is_truthy_flag(info.get("is_bound_role")), "is_bound_account": self._is_truthy_flag(info.get("is_bound_account")), "need_bind_act": self._is_truthy_flag(info.get("need_bind_act")), "need_bind_role": self._is_truthy_flag(info.get("need_bind_role")), "change_role_wait_time": self._to_int(info.get("change_role_wait_time")), "can_change_time": self._to_int(info.get("can_change_time")), "can_change_role": self._can_change_role(info), "bind_info": info, } def _format_bind_summary(self, bind_info: dict | None, *, pending: bool | None = None) -> str: snap = self._bind_snapshot(bind_info) role = snap["role_name"] or "-" area = snap["area_name"] or "-" plat = snap["plat_name"] or "-" pending_text = "" if pending is not None: pending_text = f" pending={1 if pending else 0}" return ( f"role={role} area={area} plat={plat}" f" bound_act={1 if snap['is_bound_act'] else 0}" f" bound_role={1 if snap['is_bound_role'] else 0}" f" need_act={1 if snap['need_bind_act'] else 0}" f" need_role={1 if snap['need_bind_role'] else 0}" f" wait={snap['change_role_wait_time'] if snap['change_role_wait_time'] is not None else '-'}" f" can_change_time={snap['can_change_time'] if snap['can_change_time'] is not None else '-'}" f" can={snap['can_change_role']}" f"{pending_text}" ) def _apply_bind_info_to_account(self, account: Account, bind_info: dict, status: str) -> None: role_name = str(bind_info.get("role_name") or "") account.game_name = role_name or account.game_name account.game_channel = self._role_channel(bind_info) or account.game_channel account.change_role_wait_time = self._to_int(bind_info.get("change_role_wait_time")) account.bind_status = status account.updated_at = datetime.now(timezone.utc) def _apply_esports_bind_info_to_account(self, account: Account, bind_info: dict, status: str) -> None: """将电竞手册角色状态写入专属字段,避免覆盖精英宝典数据。""" role_name = str(bind_info.get("role_name") or "") account.esports_game_name = role_name or account.esports_game_name account.esports_game_channel = self._role_channel(bind_info) or account.esports_game_channel account.esports_change_role_wait_time = self._to_int(bind_info.get("change_role_wait_time")) account.esports_can_change_time = self._to_int(bind_info.get("can_change_time")) account.esports_bind_status = status account.updated_at = datetime.now(timezone.utc) def _esports_bind_state( self, client: DouyuActivityClient, act_alias: str, ) -> dict: """查询电竞手册活动状态,接口同时返回当前角色和换绑冷却信息。""" activity_info = client.esports_bind_info(act_alias) activity_snapshot = self._bind_snapshot(activity_info) esports_bound = activity_snapshot["is_bound_act"] has_selected_role = bool(activity_snapshot["role_name"]) tx_act = activity_info.get("tx_act") or {} return { **activity_snapshot, "is_bound_role": has_selected_role, "act_alias": act_alias, "game_id": tx_act.get("gameId") or "", "esports_bound": esports_bound, "has_selected_role": has_selected_role, "bind_ready_for_confirm": has_selected_role and not esports_bound, "bind_confirmed": esports_bound, "bind_phase": "confirmed" if esports_bound else ("role_ready" if has_selected_role else "waiting_role"), "activity_bind_info": activity_info, "activity_bind_snapshot": activity_snapshot, "role_source": "activity", } @staticmethod def _esports_role_text(state: dict) -> str: role_name = str(state.get("role_name") or "") channel = " / ".join( str(part) for part in [state.get("plat_name"), state.get("area_name")] if part ) if not role_name: return "" return f"{role_name}({channel})" if channel else role_name 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 _refresh_account_points( self, client: DouyuActivityClient, account: Account, cookie: str, *, ctn: str | None = None, ) -> dict: """刷新账号积分并写回账号表。""" uid = account_uid(account, cookie) if not uid: raise DouyuActivityError("Cookie 中没有 acf_uid,无法查询积分") ctn_value = ctn or client.acf_ccn(refresh_subscribe=False) result = client.query_points(uid=uid, ctn=ctn_value) points = self._to_int(result.get("points")) account.uid = uid account.points = points update_account_profile_from_cookie(account, cookie) account.updated_at = datetime.now(timezone.utc) return {"points": points, "points_query": result} def _refresh_account_gold_balance(self, client: DouyuActivityClient, account: Account) -> dict: """刷新鱼翅和钱包兑换余额并写回账号表。""" 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.updated_at = datetime.now(timezone.utc) return { "gold_balance": account.gold_balance, "exchange_balance": account.exchange_balance, "gold": gold, "exchange_balance_query": exchange, } def _wait_points_after_payment( self, db: Session, task: DouyuTask, account: Account, client: DouyuActivityClient, cookie: str, ctn: str, result: dict, ) -> bool: """等待宝典支付到账;积分达到 300 视为开通成功。""" deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS result["payment_polling"] = True result["payment_target_points"] = 300 poll_count = 0 last_points = None while not self._stop.is_set() and time.monotonic() <= deadline: try: points_result = self._refresh_account_points(client, account, cookie, ctn=ctn) db.commit() poll_count += 1 last_points = points_result["points"] result.update(points_result) result["payment_poll_count"] = poll_count result["payment_polling"] = True if last_points is not None and last_points >= 300: result["payment_polling"] = False result["elite_opened"] = True return True self._update_task_progress( db, task, "running", f"精英宝典支付码已生成,等待开通到账(当前积分 {last_points if last_points is not None else '-'})", result, ) except Exception as exc: poll_count += 1 result["payment_poll_count"] = poll_count result["payment_poll_error"] = str(exc) self._update_task_progress(db, task, "running", f"等待开通到账: {exc}", result) if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): break result["payment_polling"] = False result["elite_opened"] = False result["points"] = last_points return False def _refresh_esports_handbook( self, client: DouyuActivityClient, account: Account, *, manual_id: str, ) -> dict: """刷新电竞手册开通状态并将积分写回账号。""" result = client.esports_user_info(manual_id=manual_id) manual_type = self._to_int(result.get("manual_type")) manual_score = self._to_int(result.get("manual_score")) account.esports_points = manual_score account.updated_at = datetime.now(timezone.utc) return { "esports_manual_type": manual_type, "esports_manual_score": manual_score, "esports_expire_time": result.get("expire_time"), "esports_user_info": result, "esports_points": manual_score, "points": manual_score, } def _wait_esports_open_after_payment( self, db: Session, task: DouyuTask, account: Account, client: DouyuActivityClient, *, manual_id: str, result: dict, baseline_manual_type: int | None, baseline_manual_score: int | None, ) -> bool: """等待电竞手册支付到账,以 manualType=1 或积分变化作为成功条件。""" deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS result["payment_polling"] = True result["esports_manual_type_baseline"] = baseline_manual_type result["esports_manual_score_baseline"] = baseline_manual_score poll_count = 0 last_manual_type = baseline_manual_type last_manual_score = baseline_manual_score while not self._stop.is_set() and time.monotonic() <= deadline: try: handbook_result = self._refresh_esports_handbook( client, account, manual_id=manual_id, ) db.commit() poll_count += 1 last_manual_type = handbook_result["esports_manual_type"] last_manual_score = handbook_result["esports_manual_score"] result.update(handbook_result) result["payment_poll_count"] = poll_count result["payment_polling"] = True opened = ( last_manual_type is not None and last_manual_type >= 1 ) or ( baseline_manual_score is not None and last_manual_score is not None and last_manual_score > baseline_manual_score ) if opened: result["payment_polling"] = False result["esports_opened"] = True return True self._update_task_progress( db, task, "running", "电竞手册支付码已生成,等待开通到账" f"(类型 {last_manual_type if last_manual_type is not None else '-'}," f"积分 {last_manual_score if last_manual_score is not None else '-'})", result, ) except Exception as exc: poll_count += 1 result["payment_poll_count"] = poll_count result["payment_poll_error"] = str(exc) self._update_task_progress(db, task, "running", f"等待电竞手册到账: {exc}", result) if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): break result["payment_polling"] = False result["esports_opened"] = False result["esports_manual_type"] = last_manual_type result["esports_manual_score"] = last_manual_score result["esports_points"] = last_manual_score result["points"] = last_manual_score return False def _wait_gold_balance_after_payment( self, db: Session, task: DouyuTask, account: Account, client: DouyuActivityClient, result: dict, baseline_gold: int | None, ) -> bool: """等待鱼翅充值到账;余额变化后写回账号表。""" deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS result["payment_polling"] = True result["baseline_gold_balance"] = baseline_gold poll_count = 0 last_gold = baseline_gold baseline_ready = baseline_gold is not None while not self._stop.is_set() and time.monotonic() <= deadline: try: balance_result = self._refresh_account_gold_balance(client, account) db.commit() poll_count += 1 last_gold = balance_result["gold_balance"] result.update(balance_result) result["payment_poll_count"] = poll_count result["payment_polling"] = True if not baseline_ready and last_gold is not None: baseline_gold = last_gold result["baseline_gold_balance"] = baseline_gold baseline_ready = True self._update_task_progress( db, task, "running", f"鱼翅支付码已生成,已记录当前余额 {last_gold},等待到账", result, ) if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): break continue changed = last_gold is not None and (baseline_gold is None or last_gold != baseline_gold) if changed: result["payment_polling"] = False result["gold_recharged"] = True return True self._update_task_progress( db, task, "running", f"鱼翅支付码已生成,等待到账(当前鱼翅 {last_gold if last_gold is not None else '-'})", result, ) except Exception as exc: poll_count += 1 result["payment_poll_count"] = poll_count result["payment_poll_error"] = str(exc) self._update_task_progress(db, task, "running", f"等待鱼翅到账: {exc}", result) if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): break result["payment_polling"] = False result["gold_recharged"] = False result["gold_balance"] = last_gold return False @staticmethod def _supplier_value(payload: dict, *keys: str): """兼容供应商将订单字段放在响应根节点、data 或 result 节点。""" data = payload.get("data") if isinstance(payload.get("data"), dict) else {} result = payload.get("result") if isinstance(payload.get("result"), dict) else {} for source in (payload, data, result): for key in keys: if source.get(key) is not None: return source[key] return None @classmethod def _supplier_order_status(cls, payload: dict) -> int | None: """提取供应商订单状态,文档约定 0-4。""" return cls._to_int(cls._supplier_value(payload, "order_status", "orderStatus", "supplier_order_status")) @classmethod def _supplier_message(cls, payload: dict) -> str: """提取供应商可展示的业务消息。""" value = cls._supplier_value(payload, "msg", "message", "error_msg") return str(value or "")[:256] @staticmethod def _supplier_result(payload: dict) -> dict: """保存必要订单状态,避免把完整供应商响应或签名暴露到任务结果。""" data = payload.get("data") if isinstance(payload.get("data"), dict) else {} response_result = payload.get("result") if isinstance(payload.get("result"), dict) else {} result = { key: value for key, value in {**payload, **data, **response_result}.items() if key not in {"sign", "cards", "card_no", "card_pwd", "recharge_arg"} } return result @staticmethod def _supplier_out_order_id(task: DouyuTask) -> str: """生成可追踪的供应商外部订单号;已有订单号必须在重试时复用。""" existing = str(task.supplier_out_order_id or "").strip() if existing: return existing batch_token = re.sub(r"[^A-Za-z0-9]", "", str(task.batch_id or "")).upper()[:16] or "LOCAL" return f"DYGF{batch_token}T{task.id}" def _wait_supplier_gold_order( self, db: Session, task: DouyuTask, client: FishFinRechargeClient, result: dict, ) -> int | None: """轮询供应商直充订单至结束状态。""" order_no = str(result["out_order_id"]) deadline = time.monotonic() + DOUYU_PAYMENT_POLL_SECONDS poll_count = 0 result["payment_polling"] = True while not self._stop.is_set() and time.monotonic() <= deadline: try: # 回调可能已在另一个数据库会话中结束订单,刷新后直接使用其结果。 db.refresh(task) if task.status in {"success", "failed"}: callback_result = task.result if isinstance(task.result, dict) else result result.update(callback_result) result["payment_polling"] = False return self._supplier_order_status(callback_result) payload = client.query_order(order_no) code = self._to_int(self._supplier_value(payload, "code")) status = self._supplier_order_status(payload) poll_count += 1 result.update({ "payment_poll_count": poll_count, "supplier_code": code, "supplier_order_status": status, "supplier_order": self._supplier_result(payload), }) if code != 200: result["payment_polling"] = False return status if status in {2, 3, 4} else 4 if status in {2, 3, 4}: result["payment_polling"] = False return status self._update_task_progress( db, task, "running", f"供应商直充订单处理中(状态 {status if status is not None else '-'})", result, ) except FishFinRechargeError as exc: poll_count += 1 result["payment_poll_count"] = poll_count result["payment_poll_error"] = str(exc) self._update_task_progress(db, task, "running", f"查询供应商订单失败: {exc}", result) if self._stop.wait(DOUYU_PAYMENT_POLL_INTERVAL): break result["payment_polling"] = False return None def _refresh_points_after_elite_gift( self, db: Session, task: DouyuTask, account: Account, client: DouyuActivityClient, cookie: str, ctn: str | None, result: dict, baseline_points: int | None, gift_count: int, ) -> dict: """赠送精英令后短轮询积分;1 个精英令约等于 10 积分。""" expected_gain = max(0, gift_count) * 10 target_points = baseline_points + expected_gain if baseline_points is not None else None result["gift_points_baseline"] = baseline_points result["gift_points_expected_gain"] = expected_gain result["gift_points_target"] = target_points last_points = None refresh_result: dict = {} for index in range(1, DOUYU_GIFT_POINTS_REFRESH_TIMES + 1): refresh_result = self._refresh_account_points(client, account, cookie, ctn=ctn) db.commit() last_points = refresh_result["points"] result.update(refresh_result) result["gift_points_refresh_count"] = index if target_points is None or (last_points is not None and last_points >= target_points): result["gift_points_confirmed"] = target_points is None or last_points is not None return refresh_result if index < DOUYU_GIFT_POINTS_REFRESH_TIMES: self._update_task_progress( db, task, "running", f"赠送精英令成功,等待积分同步(当前 {last_points if last_points is not None else '-'},预期 {target_points})", result, ) if self._stop.wait(DOUYU_GIFT_POINTS_REFRESH_INTERVAL): break result["gift_points_confirmed"] = False result["points"] = last_points return refresh_result 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 _fetch_bind_info_candidates( self, client: DouyuActivityClient, aliases: list[str], ) -> list[dict]: """按多个 actAlias 查询绑定信息,保留成功结果。""" results: list[dict] = [] for alias in aliases: if not alias: continue try: info = client.bind_info(alias, v2=True) except DouyuActivityError as exc: self._push_log("warning", f"查询绑定信息失败 act={alias}: {exc}") continue info = {**info, "act_alias": alias} self._push_log( "info", f"绑定信息 act={alias} {self._format_bind_summary(info)}", ) results.append(info) return results def _pick_bind_info( self, candidates: list[dict], *, baseline_role_name: str = "", baseline_is_bound_act: bool = False, prefer_pending: bool = True, prefer_aliases: list[str] | None = None, ) -> dict | None: """从多个 alias 结果里挑最有用的绑定信息。 - prefer_pending=True:优先选“待确认/新扫码角色”(通常来自 cjm) - prefer_aliases:在同等条件下优先指定 alias(如活动当前绑定) """ if not candidates: return None def _alias_rank(info: dict) -> int: alias = str(info.get("act_alias") or "") if not prefer_aliases: return 0 try: return prefer_aliases.index(alias) except ValueError: return len(prefer_aliases) + 1 ranked = sorted(enumerate(candidates), key=lambda item: (_alias_rank(item[1]), item[0])) ordered = [item[1] for item in ranked] if prefer_pending: for info in ordered: if self._is_pending_role( info, baseline_role_name=baseline_role_name, baseline_is_bound_act=baseline_is_bound_act, ): return info for info in ordered: if str(info.get("role_name") or "").strip(): return info return ordered[0] def _pick_current_bound_info( self, candidates: list[dict], config: dict, *, extra_prefer_aliases: list[str] | None = None, ) -> dict | None: """选当前活动已生效绑定,避免把 legacy/cjm 的待确认态当成当前角色。""" if not candidates: return None prefer = [] for alias in [*(extra_prefer_aliases or []), *self._action_act_aliases(config)]: alias = str(alias or "").strip() if alias and alias not in prefer: prefer.append(alias) bound = [ info for info in candidates if self._is_bound_act(info) and str(info.get("role_name") or "").strip() and str(info.get("act_alias") or "") in prefer ] if not bound: return None return sorted(bound, key=lambda info: prefer.index(str(info.get("act_alias") or "")))[0] def _pick_baseline_bind_info( self, candidates: list[dict], config: dict, ) -> dict | None: """选“扫码前当前已绑定角色”作为 baseline。 活动 alias(20260120QYOOB)只反映当前已绑定; cjm 才是换绑最新态。baseline 应优先活动 alias 的已绑定结果, 避免把 cjm 上的待确认新角色误当成扫码前旧角色。 """ if not candidates: return None return self._pick_current_bound_info(candidates, config) def _wait_bind_role_result( self, client: DouyuActivityClient, db: Session, task: DouyuTask, account: Account, query_aliases: list[str], result: dict, baseline_role_name: str = "", baseline_is_bound_act: bool = False, ) -> tuple[str, dict]: """生成二维码后轮询绑定信息,直到识别到待确认角色、超时或停止。""" deadline = time.monotonic() + DOUYU_BIND_ROLE_POLL_SECONDS result["bind_polling"] = True result["bind_phase"] = result.get("bind_phase") or "waiting_scan" result["bind_ready_for_confirm"] = False result["bind_confirmed"] = False result["baseline_role_name"] = baseline_role_name result["baseline_is_bound_act"] = baseline_is_bound_act result["query_act_aliases"] = query_aliases self._push_log( "info", "开始轮询绑定角色 " f"aliases={','.join(query_aliases) or '-'} " f"baseline={baseline_role_name or '-'} bound={1 if baseline_is_bound_act else 0}", ) self._update_task_progress(db, task, "running", "已生成绑定二维码,等待扫码绑定", result) poll_count = 0 last_summary = "" while not self._stop.is_set() and time.monotonic() < deadline: if self._stop.wait(DOUYU_BIND_ROLE_POLL_INTERVAL): break candidates = self._fetch_bind_info_candidates(client, query_aliases) if not candidates: result["bind_poll_error"] = "所有 actAlias 查询绑定信息失败" self._update_task_progress(db, task, "running", "等待绑定角色同步: 查询失败", result) continue poll_count += 1 bind_info = self._pick_bind_info( candidates, baseline_role_name=baseline_role_name, baseline_is_bound_act=baseline_is_bound_act, prefer_pending=True, prefer_aliases=query_aliases, ) or candidates[0] snapshot = self._bind_snapshot(bind_info) is_pending_role = self._is_pending_role( bind_info, baseline_role_name=baseline_role_name, baseline_is_bound_act=baseline_is_bound_act, ) query_alias = str(bind_info.get("act_alias") or "") summary = ( f"act={query_alias or '-'} " f"{self._format_bind_summary(bind_info, pending=is_pending_role)}" ) # 字段变化或每 3 次打印一次,避免刷屏但仍能看到过程 if summary != last_summary or poll_count == 1 or poll_count % 3 == 0: self._push_log("info", f"轮询绑定#{poll_count}: {summary}") last_summary = summary # 注意:未识别到新角色时,不要把当前已绑定角色写进 role_name, # 否则前端会把旧角色误当成“待确认角色/查询结果”。 if is_pending_role: result.update({ **snapshot, "act_alias": query_alias, "query_act_alias": query_alias, "bind_ready_for_confirm": True, "bind_confirmed": False, "bind_phase": "role_ready", "bind_polling": False, "poll_count": poll_count, "bind_summary": summary, "bind_candidates": [ { "act_alias": item.get("act_alias"), "role_name": item.get("role_name"), "is_bound_act": self._is_bound_act(item), } for item in candidates ], }) role_name = snapshot["role_name"] # 待确认角色只回传前端展示,不写入账号表,避免“未换绑成功但角色信息已变新” account.bind_status = "game_queried" account.updated_at = datetime.now(timezone.utc) self._push_log("success", f"识别到待确认角色: {role_name} (act={query_alias})") self._update_task_progress( db, task, "running", f"已识别角色: {role_name},待确认绑定", result, ) return role_name, result if snapshot["role_name"] and snapshot["is_bound_act"]: result["current_role_name"] = snapshot["role_name"] result["current_area_name"] = snapshot["area_name"] result["current_plat_name"] = snapshot["plat_name"] result.update({ "bind_info": bind_info, "act_alias": query_alias, "query_act_alias": query_alias, "is_bound_act": snapshot["is_bound_act"], "is_bound_role": snapshot["is_bound_role"], "is_bound_account": snapshot["is_bound_account"], "need_bind_act": snapshot["need_bind_act"], "need_bind_role": snapshot["need_bind_role"], "change_role_wait_time": snapshot["change_role_wait_time"], "can_change_role": snapshot["can_change_role"], "role_name": "", "area_name": "", "plat_name": "", "bind_ready_for_confirm": False, "bind_confirmed": False, "bind_phase": "waiting_scan", "bind_polling": True, "poll_count": poll_count, "bind_summary": summary, }) self._update_task_progress(db, task, "running", f"等待扫码绑定 ({summary})", result) result["bind_polling"] = False result["bind_ready_for_confirm"] = False result["bind_phase"] = "stopped" if self._stop.is_set() else "role_timeout" result["poll_count"] = poll_count if last_summary: result["bind_summary"] = last_summary self._push_log( "warning", f"轮询结束 phase={result['bind_phase']} polls={poll_count} last={last_summary or '-'}", ) return "", result 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_refresh_esports_goods( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """刷新电竞手册皮肤商城快照。""" client = self._client(cookie) result = client.list_esports_goods( manual_id=str(config["esports_manual_id"]), rid=str(config["room_id"]), ) goods = result["goods"] self._upsert_esports_goods(db, goods) account.esports_bind_status = "esports_goods_refreshed" account.updated_at = datetime.now(timezone.utc) self._mark_task( db, task, "success", f"已刷新电竞皮肤 {len(goods)} 个", {"goods_count": len(goods), "esports_store_score": result["score"], "goods": goods}, ) def _xpd_role_context(self, client: DouyuActivityClient, config: dict) -> dict: """获取小店 H5 参数 + 绑定角色信息,小店任务共用。""" embed = client.xpd_embed_query( act_alias=str(config["xpd_act_alias"]), rid=str(config["xpd_rid"]), ) role = client.xpd_get_role( embed_query=embed["query"], act_id=str(config["xpd_act_id"]), rid=str(config["xpd_rid"]), ) return {"embed": embed, "role": role} def _xpd_area_id(self, role: dict, account: Account) -> int: """角色大区: 优先使用接口值,微信=1、手Q=2,未知回退已存值。""" raw_area = role.get("area") if raw_area not in (None, ""): try: area_id = int(raw_area) except (TypeError, ValueError): area_id = 0 if area_id > 0: return area_id role_type = str(role.get("type") or "") if role_type == "wx": return 1 if role_type == "qq": return 2 return account.xpd_area_id or 1 def _apply_xpd_role_to_account(self, account: Account, role: dict, area_id: int) -> None: account.xpd_game_name = str(role.get("role_name") or "") or account.xpd_game_name account.xpd_openid = str(role.get("game_open_id") or "") or account.xpd_openid account.xpd_role_id = str(role.get("role_id") or "") or account.xpd_role_id account.xpd_plat_id = self._to_int(role.get("plat_id")) account.xpd_area_id = area_id account.updated_at = datetime.now(timezone.utc) def _execute_query_xpd_role( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """查询和平小店绑定角色。""" client = self._client(cookie) ctx = self._xpd_role_context(client, config) role = ctx["role"] if not role.get("role_id"): self._mark_task(db, task, "failed", "未获取到小店绑定角色") return area_id = self._xpd_area_id(role, account) self._apply_xpd_role_to_account(account, role, area_id) account.xpd_bind_status = "xpd_bound" db.commit() role_text = str(role.get("role_name") or "-") channel = "微信" if role.get("type") == "wx" else ("手Q" if role.get("type") == "qq" else str(role.get("type") or "-")) self._mark_task( db, task, "success", f"小店角色: {role_text}({channel})", {"role": role, "area_id": area_id}, ) def _execute_get_xpd_bind_qr( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """生成和平小店绑定二维码并轮询等待微信扫码绑定/换绑完成。 识别到新角色后仅标记"待确认",不自动回写账号,由用户手动确认绑定。 """ client = self._client(cookie) act_alias = str(config.get("xpd_act_alias") or "").strip() if not act_alias: self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias") return result = client.xpd_bind_qr(act_alias=act_alias) account.xpd_bind_status = "xpd_bind_qr_ready" account.updated_at = datetime.now(timezone.utc) db.commit() # 记录绑定前状态:已绑定账号生成二维码后必须等扫码换绑,不能立即成功 try: before = client.xpd_bind_info(act_alias=act_alias) result["before_bound"] = bool(before.get("bind_role")) result["before_role_name"] = str(before.get("role_name") or "") result["before_area_name"] = str(before.get("area_name") or "") result["before_plat_name"] = str(before.get("plat_name") or "") except Exception: result["before_bound"] = False result["before_role_name"] = "" result["bind_polling"] = True self._update_task_progress( db, task, "running", "二维码已生成,请微信扫码在小程序中绑定角色", result, ) state = self._wait_xpd_bind(db, task, client, act_alias, result) result["bind_polling"] = False if state == "stopped": self._mark_task(db, task, "stopped", "任务已停止", result) return if state == "pending": role_text = str(result.get("role_name") or "-") self._mark_task(db, task, "success", f"已识别角色: {role_text},待确认绑定", result) return self._mark_task( db, task, "failed", "未检测到小店绑定(二维码仍有效,可再次生成后扫码)", result, ) def _wait_xpd_bind( self, db: Session, task: DouyuTask, client: DouyuActivityClient, act_alias: str, result: dict, ) -> str: """轮询 bindInfo 检测绑定/换绑角色,识别到后停在"待确认",不自动回写账号。 - 绑定前未绑定:检测到 bind_role=1 即识别到待确认角色 - 绑定前已绑定(换绑):检测到角色名变化才算换绑完成,角色不变继续等 返回 "pending"=已识别待确认角色, "stopped"=任务停止, "timeout"=超时未识别。 """ before_bound = bool(result.get("before_bound")) before_role_name = str(result.get("before_role_name") or "") deadline = time.monotonic() + DOUYU_XPD_BIND_POLL_SECONDS poll_count = 0 while not self._stop.is_set() and time.monotonic() <= deadline: try: info = client.xpd_bind_info(act_alias=act_alias) poll_count += 1 result["bind_poll_count"] = poll_count result["bind_polling"] = True role_name = str(info.get("role_name") or "") bound_now = bool(info.get("bind_role")) changed = before_bound and bool(role_name) and role_name != before_role_name if (not before_bound and bound_now and role_name) or changed: result.update({key: value for key, value in info.items() if key != "raw"}) result["bind_polling"] = False result["xpd_pending_confirm"] = True return "pending" self._update_task_progress( db, task, "running", f"等待扫码绑定(第 {poll_count} 次)", result, ) except Exception as exc: poll_count += 1 result["bind_poll_count"] = poll_count result["bind_poll_error"] = str(exc) self._update_task_progress( db, task, "running", f"等待扫码绑定: {exc}", result, ) if self._stop.wait(DOUYU_XPD_BIND_POLL_INTERVAL): break result["bind_polling"] = False return "stopped" if self._stop.is_set() else "timeout" def _execute_confirm_xpd_bind( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """确认和平小店绑定:回查 bindInfo,确认绑定角色后将账号回写为已绑定。""" client = self._client(cookie) act_alias = str(config.get("xpd_act_alias") or "").strip() if not act_alias: self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias") return result = client.xpd_bind_info(act_alias=act_alias) role_name = str(result.get("role_name") or "") if not result.get("bind_role") or not role_name: account.xpd_bind_status = "xpd_not_bound" account.updated_at = datetime.now(timezone.utc) db.commit() self._mark_task(db, task, "failed", "尚未检测到小店绑定角色,请先扫码绑定", result) return # 优先用完整角色信息回写(与查询角色一致),失败时回退 bindInfo 角色名 try: ctx = self._xpd_role_context(client, config) role = ctx["role"] if role.get("role_id"): self._apply_xpd_role_to_account(account, role, self._xpd_area_id(role, account)) else: account.xpd_game_name = role_name account.updated_at = datetime.now(timezone.utc) except Exception: account.xpd_game_name = role_name account.updated_at = datetime.now(timezone.utc) account.xpd_bind_status = "xpd_bound" account.updated_at = datetime.now(timezone.utc) db.commit() result["xpd_pending_confirm"] = False result["xpd_bound"] = True self._mark_task(db, task, "success", f"小店绑定成功: {role_name}", result) def _execute_query_xpd_bind_info( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """查询和平小店绑定信息(bindInfo)。 仅查询展示,不回写账号;确认绑定由 confirm_xpd_bind 任务完成。 """ client = self._client(cookie) act_alias = str(config.get("xpd_act_alias") or "").strip() if not act_alias: self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias") return result = client.xpd_bind_info(act_alias=act_alias) status = "已绑定" if result.get("bind_role") else "未绑定" text = str(result.get("role_name") or result.get("nick") or "-") self._mark_task( db, task, "success", f"小店绑定: {status} ({text})", result, ) def _execute_refresh_xpd_goods( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """刷新和平小店商品列表快照(全局数据,任一可用 CK 即可)。""" client = self._client(cookie) ctx = self._xpd_role_context(client, config) role = ctx["role"] if not role.get("role_id"): self._mark_task(db, task, "failed", "未获取到小店绑定角色") return area_id = self._xpd_area_id(role, account) result = client.xpd_list_goods( embed_query=ctx["embed"]["query"], act_id=str(config["xpd_act_id"]), openid=str(role.get("game_open_id") or ""), roleid=str(role.get("role_id") or ""), areaid=str(area_id), ) goods = result["goods"] self._upsert_xpd_goods(db, goods) self._apply_xpd_role_to_account(account, role, area_id) account.xpd_bind_status = "xpd_goods_refreshed" db.commit() self._mark_task( db, task, "success", f"已刷新小店商品 {len(goods)} 个", {"goods_count": len(goods), "goods": goods}, ) def _execute_query_xpd_balance( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """查询和平小店点券余额。""" client = self._client(cookie) ctx = self._xpd_role_context(client, config) role = ctx["role"] if not role.get("role_id"): self._mark_task(db, task, "failed", "未获取到小店绑定角色") return area_id = self._xpd_area_id(role, account) role_plat = role.get("plat_id") plat = str(role_plat) if role_plat not in (None, "") else "1" result = client.xpd_balance( embed_query=ctx["embed"]["query"], act_id=str(config["xpd_act_id"]), openid=str(role.get("game_open_id") or ""), roleid=str(role.get("role_id") or ""), plat=plat, areaid=str(area_id), ) balance = result.get("balance") self._apply_xpd_role_to_account(account, role, area_id) account.xpd_balance = balance account.xpd_bind_status = "xpd_balance_queried" db.commit() if balance is None: self._mark_task(db, task, "failed", "未获取到小店点券余额") return self._mark_task( db, task, "success", f"小店点券余额: {balance}", {"balance": balance, "role": role, "area_id": area_id}, ) def _execute_query_xpd_fragments( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """查询和平小店扭蛋碎片数量。 优先现查角色;getrole 受限(Livelink 风控/失效)时回退账号已存角色, 保证已绑定账号仍可查询。 """ client = self._client(cookie) act_id = str(config["xpd_act_id"]) embed_query: dict = {} openid = str(account.xpd_openid or "") roleid = str(account.xpd_role_id or "") stored_plat = account.xpd_plat_id plat = str(stored_plat) if stored_plat is not None else "1" areaid = str(account.xpd_area_id or 1) role: dict = {} try: ctx = self._xpd_role_context(client, config) embed_query = ctx["embed"]["query"] role = ctx["role"] if isinstance(ctx.get("role"), dict) else {} if role.get("role_id"): role_area = self._xpd_area_id(role, account) openid = str(role.get("game_open_id") or "") or openid roleid = str(role.get("role_id") or "") or roleid role_plat = role.get("plat_id") plat = str(role_plat) if role_plat not in (None, "") else plat areaid = str(role_area) or areaid self._apply_xpd_role_to_account(account, role, role_area) except Exception: pass if not openid or not roleid: self._mark_task(db, task, "failed", "未获取到小店绑定角色,请先生成二维码扫码绑定") return result = client.xpd_fragments( embed_query=embed_query, act_id=act_id, openid=openid, roleid=roleid, plat=plat, areaid=areaid, ) fragments = result.get("fragments") account.xpd_fragments = fragments account.xpd_bind_status = "xpd_fragments_queried" account.updated_at = datetime.now(timezone.utc) db.commit() if fragments is None: self._mark_task(db, task, "failed", "未获取到小店扭蛋碎片数量") return self._mark_task( db, task, "success", f"小店扭蛋碎片: {fragments}", {"fragments": fragments, "role": role, "area_id": int(areaid)}, ) def _execute_query_xpd_purchase_records( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """查询和平小店道聚城购买记录。""" client = self._client(cookie) embed = client.xpd_embed_query( act_alias=str(config["xpd_act_alias"]), rid=str(config["xpd_rid"]), ) result = client.xpd_purchase_records( embed_query=embed["query"], act_id=str(config["xpd_act_id"]), ) account.xpd_bind_status = "xpd_purchase_records_queried" account.updated_at = datetime.now(timezone.utc) total = result.get("total") or len(result.get("records") or []) self._mark_task( db, task, "success", f"小店兑换记录 {total} 条" if total else "暂无小店兑换记录", result, ) def _execute_exchange_xpd_goods( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """兑换和平小店商品,使用本次签发的道聚城短时授权。""" 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 try: pay_type = int(payload.get("pay_type") or 1) except (TypeError, ValueError): self._mark_task(db, task, "failed", "兑换货币参数无效") return if pay_type not in (1, 5): self._mark_task(db, task, "failed", "小店兑换仅支持点券或扭蛋碎片") return goods = ( db.query(DouyuXpdGoodsSnapshot) .filter(DouyuXpdGoodsSnapshot.commodity_id == commodity_id) .first() ) if not goods: self._mark_task(db, task, "failed", "未找到小店商品快照,请先刷新商品列表") return goods_snapshot = goods.raw if isinstance(goods.raw, dict) else {} goods_raw = goods_snapshot.get("raw") if isinstance(goods_snapshot.get("raw"), dict) else goods_snapshot price_key = "iPrice" if pay_type == 1 else "iJb2Price" price = self._to_int(goods_raw.get(price_key)) if price is None: price = goods.price if pay_type == 1 else None if price is None or price <= 0: currency = "点券" if pay_type == 1 else "扭蛋碎片" self._mark_task(db, task, "failed", f"该商品不支持使用{currency}兑换") return # iGoodsLeft=-1 表示活动未公开库存,不是售罄;只有 0 才阻止兑换。 if goods.goods_left == 0: self._mark_task(db, task, "failed", "该商品库存不足,请刷新商品列表后重试") return client = self._client(cookie) embed = client.xpd_embed_query( act_alias=str(config["xpd_act_alias"]), rid=str(config["xpd_rid"]), ) role: dict = {} try: role = client.xpd_get_role( embed_query=embed["query"], act_id=str(config["xpd_act_id"]), rid=str(config["xpd_rid"]), ) if role.get("role_id"): self._apply_xpd_role_to_account(account, role, self._xpd_area_id(role, account)) except DouyuActivityError as exc: self._push_log("warning", f"小店兑换前刷新角色失败,使用已保存角色: {exc}") if not role.get("role_id") and not account.xpd_role_id: self._mark_task(db, task, "failed", "未获取到小店绑定角色,请先生成二维码扫码绑定") return result = client.xpd_exchange_goods( embed_query=embed["query"], act_id=str(config["xpd_act_id"]), rid=str(config["xpd_rid"]), commodity_id=commodity_id, price=price, picture=str(goods_raw.get("sGoodsPic") or ""), pay_type=pay_type, action_id=str(goods_raw.get("iActionId") or ""), ) if pay_type == 1 and result.get("new_balance") is not None: account.xpd_balance = result["new_balance"] if pay_type == 5 and result.get("new_balance") is not None: account.xpd_fragments = result["new_balance"] account.xpd_bind_status = "xpd_goods_exchanged" account.updated_at = datetime.now(timezone.utc) db.commit() currency = "点券" if pay_type == 1 else "扭蛋碎片" display_role = role.get("role_name") or account.xpd_game_name or "" channel = "微信" if (role.get("type") == "wx" or account.xpd_area_id == 1) else "手Q" result.update({ "goods": {**goods_raw, "commodityName": goods.name or ""}, "game_name": display_role, "game_channel": channel, "account_name": account.nickname or account.username or account.uid or f"#{account.id}", "currency": currency, }) self._mark_task(db, task, "success", f"兑换小店商品成功: {goods.name or commodity_id}({price}{currency})", result) def _execute_get_bind_qr(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) qr_act_alias = self._bind_qr_act_alias(config) query_aliases = self._query_bind_act_aliases(config) if not qr_act_alias and not query_aliases: self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias") return if qr_act_alias and qr_act_alias not in query_aliases: query_aliases = [qr_act_alias, *query_aliases] before_candidates = self._fetch_bind_info_candidates(client, query_aliases) if not before_candidates: self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") return # baseline 用活动 alias 的“当前已绑定”;pending 检测优先 cjm 的换绑最新态 before = self._pick_baseline_bind_info(before_candidates, config) or {} # 换绑冷却必须看活动当前绑定(QYOOB),不要用 cjm cooldown_info = self._pick_change_wait_bind_info(before_candidates, config) pending_before = self._pick_bind_info( before_candidates, baseline_role_name=str(before.get("role_name") or ""), baseline_is_bound_act=self._is_bound_act(before), prefer_pending=True, prefer_aliases=query_aliases, ) or before or before_candidates[0] before_snapshot = self._bind_snapshot(before) cooldown_snapshot = self._bind_snapshot(cooldown_info) current_role_name = before_snapshot["role_name"] or ( cooldown_snapshot["role_name"] if cooldown_snapshot["is_bound_act"] else "" ) wait_time = cooldown_snapshot["change_role_wait_time"] self._push_log( "info", "绑定前状态 " f"qr_act={qr_act_alias or '-'} query={','.join(query_aliases)} " f"baseline_hit={before.get('act_alias') or '-'} {self._format_bind_summary(before)} " f"cooldown_hit={cooldown_info.get('act_alias') or '-'} " f"{self._format_bind_summary(cooldown_info)} " f"pending_hit={pending_before.get('act_alias') or '-'} " f"{self._format_bind_summary(pending_before)}", ) # 生成二维码前强制检查换绑冷却:冷却中直接失败,绝不发码 if self._is_change_cooling(cooldown_info): role_label = current_role_name or cooldown_snapshot["role_name"] or "当前角色" wait_text = self._format_wait_time(wait_time) or "冷却中" self._push_log( "warning", f"换绑冷却中,跳过生成二维码 role={role_label} wait={wait_time if wait_time is not None else '-'} " f"can={cooldown_info.get('can_change_role')}", ) result = { "act_alias": qr_act_alias or cooldown_info.get("act_alias") or before.get("act_alias"), "query_act_alias": cooldown_info.get("act_alias") or before.get("act_alias"), "query_act_aliases": query_aliases, "before_bind_info": before, "cooldown_bind_info": cooldown_info, **cooldown_snapshot, "current_role_name": role_label if role_label != "当前角色" else current_role_name, "current_area_name": cooldown_snapshot["area_name"] or before_snapshot["area_name"], "current_plat_name": cooldown_snapshot["plat_name"] or before_snapshot["plat_name"], "change_role_wait_time": wait_time, "change_role_wait_text": self._format_wait_time(wait_time), "bind_ready_for_confirm": False, "bind_confirmed": bool(cooldown_snapshot["is_bound_act"] or before_snapshot["is_bound_act"]), "bind_phase": "change_waiting", "bind_polling": False, } self._apply_bind_info_to_account( account, cooldown_info if cooldown_snapshot["role_name"] else before, "bind_confirmed" if result["bind_confirmed"] else "game_queried", ) self._mark_task( db, task, "failed", f"{role_label} 暂不能换绑,剩余 {wait_text}", result, ) return if not qr_act_alias: self._mark_task(db, task, "failed", "请先配置绑定二维码活动 actAlias") return self._push_log( "info", f"换绑校验通过,开始生成二维码 act={qr_act_alias} " f"role={current_role_name or '-'} wait={wait_time if wait_time is not None else 0}", ) qr_result = client.get_bind_qr(qr_act_alias) result = { **qr_result, "act_alias": qr_act_alias, "query_act_aliases": query_aliases, "before_bind_info": before, "current_role_name": current_role_name, "current_area_name": before_snapshot["area_name"], "current_plat_name": before_snapshot["plat_name"], "role_name": "", "area_name": "", "plat_name": "", "bind_ready_for_confirm": False, "bind_confirmed": False, "bind_phase": "waiting_scan", "bind_polling": True, } account.bind_status = "bind_qr_generated" account.updated_at = datetime.now(timezone.utc) # 关键:先把二维码 progress 出去,前端 running 期间即可弹窗扫码。 self._update_task_progress(db, task, "running", "已生成绑定二维码,等待扫码绑定", result) role_name, result = self._wait_bind_role_result( client, db, task, account, query_aliases, result, baseline_role_name=current_role_name, baseline_is_bound_act=before_snapshot["is_bound_act"], ) if role_name: self._mark_task(db, task, "success", f"已识别角色: {role_name},待确认绑定", result) return if result.get("bind_phase") == "stopped": self._mark_task(db, task, "stopped", "任务已停止", result) return last_summary = str(result.get("bind_summary") or "") timeout_msg = "已生成绑定二维码,未检测到新扫码角色" if current_role_name: timeout_msg = f"{timeout_msg}(当前仍是 {current_role_name})" if last_summary: timeout_msg = f"{timeout_msg} | {last_summary}" self._mark_task(db, task, "success", timeout_msg, result) def _execute_confirm_bind(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) confirm_alias = self._confirm_act_alias(config) query_aliases = self._query_bind_act_aliases(config) if confirm_alias and confirm_alias not in query_aliases: query_aliases = [confirm_alias, *query_aliases] if not confirm_alias: self._mark_task(db, task, "failed", "请先配置确认绑定活动 actAlias") return if not query_aliases: self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias") return before_candidates = self._fetch_bind_info_candidates(client, query_aliases) if not before_candidates: self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") return before = self._pick_bind_info( before_candidates, baseline_role_name="", baseline_is_bound_act=False, prefer_pending=True, prefer_aliases=query_aliases, ) or before_candidates[0] before_snapshot = self._bind_snapshot(before) role_name = before_snapshot["role_name"] query_alias = str(before.get("act_alias") or "") # 确认前“已生效绑定”角色(bound_act=1),确认失败/回查失败时写库用,避免待确认角色污染 game_name before_bound = self._pick_current_bound_info( before_candidates, config, extra_prefer_aliases=[confirm_alias], ) self._push_log( "info", f"确认前状态 confirm_act={confirm_alias or '-'} hit={query_alias or '-'} " f"{self._format_bind_summary(before)}", ) if not role_name: self._mark_task( db, task, "failed", "尚未识别到待确认角色,请先扫码完成绑定", { "act_alias": confirm_alias or query_alias, "query_act_alias": query_alias, "query_act_aliases": query_aliases, "before_bind_info": before, **before_snapshot, "bind_ready_for_confirm": False, "bind_confirmed": False, "bind_phase": "waiting_role", }, ) return before_is_current_bound = ( before_bound is not None and str(before.get("act_alias") or "") == str(before_bound.get("act_alias") or "") and role_name == str(before_bound.get("role_name") or "") ) if before_is_current_bound: self._apply_bind_info_to_account(account, before_bound, "bind_confirmed") self._mark_task( db, task, "success", f"已绑定: {role_name}", { "act_alias": confirm_alias or query_alias, "query_act_alias": query_alias, "query_act_aliases": query_aliases, "before_bind_info": before_bound, **self._bind_snapshot(before_bound), "bind_ready_for_confirm": True, "bind_confirmed": True, "bind_phase": "confirmed", }, ) return # 确认接口优先用配置的确认 alias;没有则回退到命中查询的 alias use_confirm_alias = confirm_alias or query_alias try: confirm_result = client.confirm_bind(use_confirm_alias) except DouyuActivityError as exc: confirm_msg = str(exc) self._push_log("warning", f"确认绑定接口失败: {confirm_msg}") # 待绑定游戏账号侧换绑限制(未到换绑时间等)导致确认失败:保留原绑定并给出明确提示 if before_bound is not None: self._apply_bind_info_to_account(account, before_bound, "game_queried") else: account.bind_status = "game_queried" account.updated_at = datetime.now(timezone.utc) self._mark_task( db, task, "failed", f"待绑定游戏账号({role_name or '-'})未到换绑时间(不是斗鱼/虎牙账号),请重新换账号扫码绑定", { "act_alias": use_confirm_alias, "query_act_alias": query_alias, "query_act_aliases": query_aliases, "before_bind_info": before, **before_snapshot, "confirm_error": confirm_msg, "bind_ready_for_confirm": True, "bind_confirmed": False, "bind_phase": "confirm_failed", }, ) return confirm_raw = confirm_result.get("raw") or {} self._push_log( "info", f"确认绑定接口返回 act={use_confirm_alias} " f"error={confirm_raw.get('error')} msg={confirm_raw.get('msg') or '-'}", ) def _pick_bound_after(candidates: list[dict]) -> dict | None: """确认后回查:只认活动 alias 上的已生效绑定(bound_act=1)。""" return self._pick_current_bound_info( candidates, config, extra_prefer_aliases=[use_confirm_alias], ) try: after_candidates = self._fetch_bind_info_candidates(client, query_aliases) if not after_candidates: raise DouyuActivityError("确认后回查绑定信息失败") after = _pick_bound_after(after_candidates) # 已生效绑定存在同步延迟:确认接口已成功但未生效时短轮询等待 if after is None: for _ in range(DOUYU_CONFIRM_EFFECT_POLL_TIMES): if self._stop.wait(DOUYU_CONFIRM_EFFECT_POLL_INTERVAL): break after_candidates = self._fetch_bind_info_candidates(client, query_aliases) if not after_candidates: break after = _pick_bound_after(after_candidates) if after is not None: break if after is None: # 已生效绑定始终未出现:确认未生效(或同步延迟超时),保留原绑定 if before_bound is not None: self._apply_bind_info_to_account(account, before_bound, "game_queried") else: account.bind_status = "game_queried" account.updated_at = datetime.now(timezone.utc) self._mark_task( db, task, "failed", f"待绑定游戏账号({role_name or '-'})未到换绑时间(不是斗鱼/虎牙账号),请重新换账号扫码绑定", { "act_alias": use_confirm_alias, "query_act_alias": query_alias, "query_act_aliases": query_aliases, "before_bind_info": before, "confirm": confirm_result, "after_bind_info": None, **before_snapshot, "bind_ready_for_confirm": True, "bind_confirmed": False, "bind_phase": "confirm_failed", "confirm_wait_error": "确认后短轮询未等到已生效绑定", }, ) return self._push_log( "info", f"确认后回查 hit={after.get('act_alias') or '-'} " f"{self._format_bind_summary(after)}", ) except DouyuActivityError as exc: # 查询接口异常:确认接口已成功时按成功处理,但保留错误信息。 # 写库优先确认前已生效绑定,避免待确认角色被误写入。 if before_bound is not None: self._apply_bind_info_to_account(account, before_bound, "bind_confirmed") else: account.bind_status = "bind_confirmed" account.updated_at = datetime.now(timezone.utc) self._mark_task( db, task, "success", f"绑定成功: {role_name}", { "act_alias": use_confirm_alias, "query_act_alias": query_alias, "query_act_aliases": query_aliases, "before_bind_info": before, "confirm": confirm_result, **before_snapshot, "bind_ready_for_confirm": True, "bind_confirmed": True, "bind_phase": "confirmed", "refresh_error": str(exc), }, ) return after_snapshot = self._bind_snapshot(after) final_role_name = after_snapshot["role_name"] or role_name self._apply_bind_info_to_account(account, after, "bind_confirmed") self._mark_task( db, task, "success", f"绑定成功: {final_role_name}", { "act_alias": use_confirm_alias, "query_act_alias": after.get("act_alias") or query_alias, "query_act_aliases": query_aliases, "before_bind_info": before, "confirm": confirm_result, "after_bind_info": after, **after_snapshot, "bind_ready_for_confirm": True, "bind_confirmed": True, "bind_phase": "confirmed", }, ) 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) act_alias = self._confirm_act_alias(config) or self._bind_qr_act_alias(config) if not act_alias: self._mark_task(db, task, "failed", "请先配置开通宝典活动 actAlias") return result = client.create_elite_qr( ctn=ctn, act_alias=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._update_task_progress(db, task, "running", "精英宝典支付码已生成,等待开通到账", result) opened = self._wait_points_after_payment(db, task, account, client, cookie, ctn, result) if self._stop.is_set(): self._mark_task(db, task, "stopped", "任务已停止", result) return if opened: account.bind_status = "elite_opened" account.updated_at = datetime.now(timezone.utc) self._mark_task(db, task, "success", f"精英宝典已开通,积分: {account.points}", result) return self._mark_task( db, task, "failed", f"未检测到精英宝典开通到账,当前积分: {account.points if account.points is not None else '-'}", result, ) def _execute_prepare_esports_bind( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """打开电竞手册绑定面板:查活动状态、当前角色和换绑冷却。""" client = self._client(cookie) act_alias = str(config.get("esports_act_alias") or "").strip() if not act_alias: self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias") return state = self._esports_bind_state(client, act_alias) esports_bound = state["esports_bound"] role_text = self._esports_role_text(state) self._apply_esports_bind_info_to_account( account, state, "esports_bound" if esports_bound else ("esports_bind_ready" if role_text else "game_not_bound"), ) # 二维码用于重新选择角色,不应因为已有角色或换绑冷却而隐藏。 # 冷却是否允许最终由 actBind 返回结果决定。 qr_result = client.get_esports_bind_qr(act_alias) result = { **state, **qr_result, "esports_bind_dialog": True, "can_open_role_selector": True, } if esports_bound: message = f"电竞手册已绑定: {role_text or '-'},可扫码换绑" elif role_text: message = f"当前角色: {role_text},可扫码切换角色或直接完成绑定" else: message = "请扫码选择游戏角色,完成后查询最新角色" self._mark_task(db, task, "success", message, result) def _execute_get_esports_bind_qr( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """生成电竞手册切换角色用的腾讯入口。""" client = self._client(cookie) act_alias = str(config.get("esports_act_alias") or "").strip() if not act_alias: self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias") return state = self._esports_bind_state(client, act_alias) qr_result = client.get_esports_bind_qr(act_alias) result = { **state, **qr_result, "esports_bind_dialog": True, "can_open_role_selector": True, "bind_phase": "switching_role", } account.esports_bind_status = "esports_role_switching" account.updated_at = datetime.now(timezone.utc) self._mark_task( db, task, "success", "请在腾讯页面选择角色,返回后查询最新角色", result, ) def _execute_confirm_esports_bind( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """通过 actBind 确认电竞手册绑定,并回读唯一状态接口确认结果。""" client = self._client(cookie) act_alias = str(config.get("esports_act_alias") or "").strip() if not act_alias: self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias") return account.esports_bind_status = "esports_bind_confirming" account.updated_at = datetime.now(timezone.utc) self._update_task_progress( db, task, "running", "正在确认电竞手册绑定", { "bind_phase": "confirming", "bind_confirmed": False, }, ) confirm_result = client.confirm_esports_bind( act_alias, room_id=str(config.get("room_id") or "9263298"), ) after_state = self._esports_bind_state(client, act_alias) result = { **after_state, "confirm": confirm_result, "bind_phase": "confirmed" if after_state["esports_bound"] else "confirm_failed", } if not after_state["esports_bound"]: self._apply_esports_bind_info_to_account(account, after_state, "esports_bind_ready") self._mark_task( db, task, "failed", f"电竞手册绑定未生效: {self._esports_role_text(after_state) or '-'}", result, ) return self._apply_esports_bind_info_to_account(account, after_state, "esports_bound") self._mark_task( db, task, "success", f"电竞手册绑定成功: {self._esports_role_text(after_state) or '-'}", result, ) def _execute_query_esports_game_name( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """查询电竞手册活动返回的最新角色和换绑冷却状态。""" client = self._client(cookie) act_alias = str(config.get("esports_act_alias") or "").strip() if not act_alias: self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias") return state = self._esports_bind_state(client, act_alias) role_name = state["role_name"] is_bound = state["esports_bound"] self._apply_esports_bind_info_to_account( account, state, "esports_bound" if is_bound else ("esports_bind_ready" if role_name else "game_not_bound"), ) result = { **state, "esports_bind_dialog": True, "can_open_role_selector": True, } if role_name: message = f"最新角色: {self._esports_role_text(state)}" else: message = "未查询到游戏角色,请先切换角色" self._mark_task(db, task, "success", message, result) def _execute_create_esports_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) act_alias = str(config.get("esports_act_alias") or "").strip() manual_id = str(config.get("esports_manual_id") or "").strip() if not act_alias or not manual_id: self._mark_task(db, task, "failed", "请先配置电竞手册活动 actAlias 和 manualID") return baseline_manual_type = None baseline_manual_score = None baseline_result: dict = {} try: baseline_result = self._refresh_esports_handbook(client, account, manual_id=manual_id) baseline_manual_type = baseline_result["esports_manual_type"] baseline_manual_score = baseline_result["esports_manual_score"] db.commit() except Exception as exc: self._push_log("warning", f"生成电竞手册支付码前查询活动状态失败: {exc}") if baseline_manual_type is not None and baseline_manual_type >= 1: account.esports_bind_status = "esports_opened" account.updated_at = datetime.now(timezone.utc) self._mark_task( db, task, "success", f"电竞手册已开通,积分: {baseline_manual_score if baseline_manual_score is not None else '-'}", {**baseline_result, "esports_opened": True, "payment_polling": False}, ) return result = client.create_esports_qr( ctn=ctn, act_alias=act_alias, amount=int(config["esports_amount"]), room_id=str(config["room_id"]), ) result.update(baseline_result) account.esports_bind_status = "esports_qr_created" account.updated_at = datetime.now(timezone.utc) self._update_task_progress(db, task, "running", "电竞手册支付码已生成,等待开通到账", result) opened = self._wait_esports_open_after_payment( db, task, account, client, manual_id=manual_id, result=result, baseline_manual_type=baseline_manual_type, baseline_manual_score=baseline_manual_score, ) if self._stop.is_set(): self._mark_task(db, task, "stopped", "任务已停止", result) return if opened: account.esports_bind_status = "esports_opened" account.updated_at = datetime.now(timezone.utc) self._mark_task( db, task, "success", f"电竞手册已开通,积分: {account.esports_points if account.esports_points is not None else '-'}", result, ) return self._mark_task( db, task, "failed", "未检测到电竞手册开通到账" f",类型: {result.get('esports_manual_type', '-')}," f"积分: {result.get('esports_manual_score', '-')}", result, ) def _execute_query_esports_points( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """查询电竞手册积分。""" manual_id = str(config.get("esports_manual_id") or "").strip() if not manual_id: self._mark_task(db, task, "failed", "请先配置电竞手册 manualID") return client = self._client(cookie) result = self._refresh_esports_handbook(client, account, manual_id=manual_id) account.esports_bind_status = "esports_points_queried" account.updated_at = datetime.now(timezone.utc) points = result["esports_points"] self._mark_task(db, task, "success", f"电竞积分: {points if points is not None else '-'}", result) def _execute_donate_esports_gift( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, *, gift_name: str, config_gift_id_key: str, config_skin_id_key: str, ): """赠送电竞手册任务礼物并刷新独立积分。""" payload = self._task_payload(task) try: gift_count = max(1, int(payload.get("gift_count") or payload.get("count") or 1)) except (TypeError, ValueError): self._mark_task(db, task, "failed", "赠送数量必须是正整数") return manual_id = str(config.get("esports_manual_id") or "").strip() gift_id = str(payload.get("gift_id") or config.get(config_gift_id_key) or "").strip() skin_id = str(payload.get("skin_id") or config.get(config_skin_id_key) or "").strip() room_id = str(payload.get("room_id") or config.get("room_id") or "").strip() if not manual_id or not gift_id or not skin_id or not room_id: self._mark_task(db, task, "failed", "请先完整配置电竞手册、房间和礼物参数") return client = self._client(cookie) baseline_points = account.esports_points try: baseline = self._refresh_esports_handbook(client, account, manual_id=manual_id) baseline_points = baseline["esports_points"] db.commit() except Exception as exc: self._push_log("warning", f"赠送{gift_name}前刷新电竞积分失败: {exc}") result = client.donate_esports_gift( gift_name=gift_name, gift_count=gift_count, room_id=room_id, gift_id=gift_id, skin_id=skin_id, ) result.update( { "gift_name": gift_name, "gift_id": gift_id, "skin_id": skin_id, "gift_count": gift_count, "esports_points_baseline": baseline_points, } ) refresh_errors = [] try: result.update(self._refresh_account_gold_balance(client, account)) except Exception as exc: refresh_errors.append(f"鱼翅余额: {exc}") try: points_result = self._refresh_esports_handbook(client, account, manual_id=manual_id) result.update(points_result) result["esports_points_after_gift"] = points_result["esports_points"] result["esports_points_changed"] = ( baseline_points is not None and points_result["esports_points"] is not None and points_result["esports_points"] != baseline_points ) except Exception as exc: refresh_errors.append(f"电竞积分: {exc}") if refresh_errors: result["refresh_errors"] = refresh_errors account.esports_bind_status = "esports_gift_donated" account.updated_at = datetime.now(timezone.utc) message = f"赠送{gift_name}成功: {gift_count}" if account.gold_balance is not None: message += f",鱼翅余额: {account.gold_balance}" if account.esports_points is not None: message += f",电竞积分: {account.esports_points}" self._mark_task(db, task, "success", message, result) def _execute_donate_esports_chicken_gift( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """赠送冠军鸡腿。""" self._execute_donate_esports_gift( db, task, account, cookie, config, gift_name="冠军鸡腿", config_gift_id_key="esports_chicken_gift_id", config_skin_id_key="esports_chicken_skin_id", ) def _execute_donate_esports_firework_gift( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """赠送冠军烟花。""" self._execute_donate_esports_gift( db, task, account, cookie, config, gift_name="冠军烟花", config_gift_id_key="esports_firework_gift_id", config_skin_id_key="esports_firework_skin_id", ) 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) channel = str(config.get("gold_recharge_channel") or "wechat_qr") if channel == "supplier_api": try: self._execute_create_gold_supplier_order(db, task, account, cookie, config, amount) except FishFinRechargeError as exc: self._mark_task(db, task, "failed", str(exc), {"recharge_channel": "supplier_api"}) return client = self._client(cookie) baseline_gold = account.gold_balance try: baseline = self._refresh_account_gold_balance(client, account) baseline_gold = baseline["gold_balance"] db.commit() except Exception as exc: self._push_log("warning", f"生成鱼翅码前刷新余额失败: {exc}") 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._update_task_progress(db, task, "running", f"鱼翅 {amount} 元支付码已生成,等待到账", result) recharged = self._wait_gold_balance_after_payment(db, task, account, client, result, baseline_gold) if self._stop.is_set(): self._mark_task(db, task, "stopped", "任务已停止", result) return if recharged: account.bind_status = "gold_recharged" 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 '-'}", result, ) return self._mark_task( db, task, "failed", f"未检测到鱼翅到账,当前余额: {account.gold_balance if account.gold_balance is not None else '-'}", result, ) def _execute_create_gold_supplier_order( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, amount: int, ) -> None: """创建供应商鱼翅直充订单并轮询订单状态。""" product_id = str(config.get("gold_api_product_id") or "").strip() template_name = str(config.get("gold_api_account_template_name") or "斗鱼昵称").strip() if not product_id: raise FishFinRechargeError("请先在配置中填写供应商直充商品 ID") # 充值商品按斗鱼昵称识别账号,UID 只能作为审计信息,不能作为充值值。 update_account_profile_from_cookie(account, cookie) recharge_account = str(account.nickname or "").strip() if not recharge_account: raise FishFinRechargeError("账号缺少斗鱼昵称,无法发起供应商直充") # 首次生成后持久化,网络重试或进程重启都继续查询同一笔订单。 order_no = self._supplier_out_order_id(task) task.supplier_out_order_id = order_no db.commit() # pay_amount 是用户选择的充值面值;goodsFaceValue=0.993 是供货成本,不能作为支付金额。 pay_amount = Decimal(amount) def trace(event: dict) -> None: """将脱敏供应商协议信息输出到任务日志,便于线上联调。""" stage = event.get("stage") if stage == "request": params = event.get("params") or {} self._push_log( "info", "供应商直充 | 下单 " f"| 外部单号={params.get('out_order_id') or '-'} " f"| 数量={params.get('buy_num') or '-'} " f"| 金额={params.get('pay_amount') or '-'} " f"| 商品={params.get('product_id') or '-'}", ) if event.get("json_body"): self._push_log( "debug", "供应商协议 | 请求 " f"| {event.get('method')} {event.get('path')} " f"| 签名摘要={event.get('sign_digest')} " f"| 参数={FishFinRechargeClient._json_text(event['json_body'])}", ) elif stage == "response": status = self._to_int(event.get("order_status")) status_labels = {0: "待处理", 1: "处理中", 2: "成功", 3: "失败", 4: "异常"} status_text = status_labels.get(status, "-") reason = str(event.get("fail_reason") or event.get("message") or "-") self._push_log( "info", "供应商直充 | 响应 " f"| HTTP={event.get('http_status') or '-'} " f"| 业务码={event.get('code') or '-'} " f"| 外部单号={event.get('out_order_id') or '-'} " f"| 供应商单号={event.get('order_id') or '-'} " f"| 状态={status_text} " f"| 提示={reason}", ) if event.get("response_body"): self._push_log( "debug", "供应商协议 | 响应 " f"| HTTP={event.get('http_status')} " f"| 内容={FishFinRechargeClient._json_text(event['response_body'])}", ) client = FishFinRechargeClient(FishFinRechargeConfig.from_env(), trace=trace) order_payload = client.create_order( buy_num=amount, pay_amount=pay_amount, out_order_id=order_no, product_id=product_id, recharge_arg=[{"templateName": template_name, "templateVal": recharge_account}], order_type=0, notify_url=client.config.notify_url, ) code = self._to_int(self._supplier_value(order_payload, "code")) status = self._supplier_order_status(order_payload) result = { "recharge_channel": "supplier_api", "out_order_id": order_no, "order_id": self._supplier_value(order_payload, "order_id", "orderId"), "recharge_account": recharge_account, "douyu_uid": str(account.uid or "").strip(), "buy_num": amount, "product_id": product_id, "pay_amount": format(pay_amount.normalize(), "f"), "order_type": 0, "supplier_code": code, "supplier_order_status": status, "supplier_order": self._supplier_result(order_payload), } if code != 200: self._mark_task(db, task, "failed", self._supplier_message(order_payload) or "供应商创建直充订单失败", result) return account.bind_status = "gold_api_order_created" account.updated_at = datetime.now(timezone.utc) self._update_task_progress(db, task, "running", "供应商直充订单已创建,等待到账", result) if status not in {2, 3, 4}: status = self._wait_supplier_gold_order(db, task, client, result) if self._stop.is_set(): self._mark_task(db, task, "stopped", "任务已停止", result) return if status == 2: account.bind_status = "gold_recharged" account.updated_at = datetime.now(timezone.utc) self._mark_task(db, task, "success", "供应商直充成功", result) return if status in {3, 4}: self._mark_task(db, task, "failed", "供应商直充失败", result) return self._mark_task(db, task, "failed", "供应商直充订单查询超时", 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) ctn = None baseline_points = account.points try: ctn = client.acf_ccn(refresh_subscribe=False) baseline_result = self._refresh_account_points(client, account, cookie, ctn=ctn) db.commit() baseline_points = baseline_result["points"] except Exception as exc: self._push_log("warning", f"赠送精英令前刷新积分失败: {exc}") 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"]), ) result["gift_points_baseline"] = baseline_points refresh_errors = [] try: result.update(self._refresh_account_gold_balance(client, account)) except Exception as exc: refresh_errors.append(f"鱼翅余额: {exc}") try: result.update( self._refresh_points_after_elite_gift( db, task, account, client, cookie, ctn, result, baseline_points, gift_count, ) ) except Exception as exc: refresh_errors.append(f"积分: {exc}") if refresh_errors: result["refresh_errors"] = refresh_errors account.bind_status = "gift_donated" account.updated_at = datetime.now(timezone.utc) message = f"赠送精英令成功: {gift_count}" if account.gold_balance is not None: message += f",鱼翅余额: {account.gold_balance}" if account.points is not None: message += f",积分: {account.points}" if result.get("gift_points_target") is not None and not result.get("gift_points_confirmed"): message += f"(未确认涨到 {result['gift_points_target']})" self._mark_task(db, task, "success", message, 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) result = self._refresh_account_points(client, account, cookie, ctn=ctn) account.bind_status = "points_queried" account.updated_at = datetime.now(timezone.utc) points = result["points"] 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) # 兑换成功后自动刷新积分,更新账号最新积分信息(失败不阻断兑换成功) points_refresh = None try: points_refresh = self._refresh_account_points(client, account, cookie, ctn=ctn) except Exception as exc: self._push_log("warning", f"兑换后刷新积分失败: {exc}") self._mark_task( db, task, "success", f"兑换成功: {(goods.name if goods else '') or commodity_id}", { "goods": goods.raw if goods else None, "game_name": account.game_name or "", "game_channel": account.game_channel or "", "account_name": account.nickname or account.username or account.uid or f"#{account.id}", **result, **(points_refresh or {}), }, ) def _execute_exchange_esports_goods( self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict, ): """兑换电竞手册皮肤,并同步电竞积分。""" 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 try: quantity = max(1, int(payload.get("quantity") or payload.get("num") or 1)) except (TypeError, ValueError): self._mark_task(db, task, "failed", "兑换数量必须是正整数") return manual_id = str(config.get("esports_manual_id") or "").strip() room_id = str(config.get("room_id") or "").strip() if not manual_id or not room_id: self._mark_task(db, task, "failed", "请先配置电竞手册 manualID 和房间 ID") return client = self._client(cookie) baseline_points = account.esports_points try: baseline = self._refresh_esports_handbook(client, account, manual_id=manual_id) baseline_points = baseline["esports_points"] db.commit() except Exception as exc: self._push_log("warning", f"兑换电竞皮肤前刷新积分失败: {exc}") result = client.exchange_esports_goods( manual_id=manual_id, rid=room_id, commodity_id=commodity_id, quantity=quantity, ) goods = ( db.query(DouyuEsportsGoodsSnapshot) .filter(DouyuEsportsGoodsSnapshot.commodity_id == commodity_id) .first() ) result["goods"] = goods.raw if goods else None result["esports_points_baseline"] = baseline_points try: points_result = self._refresh_esports_handbook(client, account, manual_id=manual_id) result.update(points_result) result["esports_points_after_exchange"] = points_result["esports_points"] except Exception as exc: result["esports_points_refresh_error"] = str(exc) account.esports_bind_status = "esports_goods_exchanged" account.updated_at = datetime.now(timezone.utc) name = (goods.name if goods else "") or commodity_id message = f"兑换电竞皮肤成功: {name}" if quantity > 1: message += f" x{quantity}" if account.esports_points is not None: message += f",电竞积分: {account.esports_points}" result["game_name"] = account.esports_game_name or "" result["game_channel"] = account.esports_game_channel or "" result["account_name"] = account.nickname or account.username or account.uid or f"#{account.id}" self._mark_task(db, task, "success", message, result) def _execute_query_game_name(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) query_aliases = self._query_bind_act_aliases(config) if not query_aliases: self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias") return candidates = self._fetch_bind_info_candidates(client, query_aliases) if not candidates: self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") return # 1) 优先“已生效绑定”角色(bound_act=1 且有角色名,通常是活动 alias)。 # 避免把扫码后未确认的新角色当成当前绑定结果。 bound_info = self._pick_current_bound_info(candidates, config) # 2) 待确认角色(cjm 扫码后未确认;无已绑定时也用于首次绑定展示) pending_info = self._pick_bind_info( candidates, prefer_pending=True, prefer_aliases=query_aliases, ) or candidates[0] pending_role = str(pending_info.get("role_name") or "").strip() bound_role = str(bound_info.get("role_name") or "") if bound_info else "" has_pending = bool(pending_role) and (pending_role != bound_role or not bound_info) # 查询结果角色 = 已生效绑定;首次绑定(无绑定)时回退待确认角色 bind_info = bound_info if bound_info else (pending_info if has_pending else None) snapshot = self._bind_snapshot(bind_info) if bind_info else {} role_name = snapshot.get("role_name") or "" is_bound = snapshot.get("is_bound_act", False) source_alias = str((bind_info or {}).get("act_alias") or "") summary = ( f"act={source_alias or '-'} {self._format_bind_summary(bind_info)}" if bind_info else "act=- role=-" ) self._push_log( "info", "查询角色 " f"aliases={','.join(query_aliases)} bound_hit={(bound_info or {}).get('act_alias') or '-'} " f"pending_hit={pending_info.get('act_alias') or '-'} " f"{self._format_bind_summary(bind_info) if bind_info else 'role=- bound_act=-'}", ) # 只有已生效绑定才写账号表;待确认角色不污染 game_name if bind_info and is_bound: self._apply_bind_info_to_account(account, bind_info, "game_queried") else: account.bind_status = "game_queried" if has_pending else "game_not_bound" account.updated_at = datetime.now(timezone.utc) result = { "act_alias": source_alias, "query_act_alias": source_alias, "query_act_aliases": query_aliases, **snapshot, "bind_ready_for_confirm": has_pending, "bind_confirmed": is_bound, "bind_phase": ( "confirmed" if is_bound else ("role_ready" if role_name else "waiting_role") ), "bind_summary": summary, "bind_candidates": [ { "act_alias": item.get("act_alias"), "role_name": item.get("role_name"), "is_bound_act": self._is_bound_act(item), } for item in candidates ], } # 已绑定角色之外另有待确认新角色时,单独字段展示,不覆盖 role_name if has_pending and bound_info and pending_role != bound_role: result["pending_role_name"] = pending_role result["pending_area_name"] = str(pending_info.get("area_name") or "") result["pending_plat_name"] = str(pending_info.get("plat_name") or "") if not bind_info: message = f"未获取到游戏名 | {summary}" elif is_bound: if has_pending and pending_role != bound_role: message = f"当前已绑定: {bound_role},待确认: {pending_role} | {summary}" else: message = f"当前已绑定: {bound_role} | {summary}" else: message = f"待确认角色: {role_name} | {summary}" self._mark_task(db, task, "success" if role_name else "failed", message, result) def _pick_change_wait_bind_info(self, candidates: list[dict], config: dict) -> dict | None: """换绑倒计时优先看活动当前绑定(QYOOB),不是 cjm 换绑最新态。""" if not candidates: return None # 冷却是“当前已生效绑定”的属性,不能用 legacy/cjm 的待确认角色判断。 current_bound = self._pick_current_bound_info(candidates, config) if current_bound is not None: return current_bound action_aliases = self._action_act_aliases(config) return self._pick_bind_info( [info for info in candidates if str(info.get("act_alias") or "") in action_aliases], prefer_pending=False, prefer_aliases=action_aliases, ) def _execute_query_change_bind_time(self, db: Session, task: DouyuTask, account: Account, cookie: str, config: dict): client = self._client(cookie) query_aliases = self._query_bind_act_aliases(config) if not query_aliases: self._mark_task(db, task, "failed", "请先配置绑定活动 actAlias") return candidates = self._fetch_bind_info_candidates(client, query_aliases) if not candidates: self._mark_task(db, task, "failed", "查询绑定信息失败,请检查 Cookie 或 actAlias") return for item in candidates: self._push_log( "info", f"查询换绑时间候选 act={item.get('act_alias') or '-'} " f"{self._format_bind_summary(item)}", ) # 换绑时间看“当前已绑定”活动态,不要优先 cjm(cjm 常无 changeRoleWaitTime) bind_info = self._pick_change_wait_bind_info(candidates, config) or candidates[0] snapshot = self._bind_snapshot(bind_info) wait_time = snapshot["change_role_wait_time"] account.change_role_wait_time = wait_time account.bind_status = "change_time_queried" account.updated_at = datetime.now(timezone.utc) source_alias = str(bind_info.get("act_alias") or "") wait_text = self._format_wait_time(wait_time) can_change = snapshot["can_change_role"] role_name = snapshot["role_name"] or "-" if wait_time is None: if can_change is False: status_text = "不可换绑(接口未返回倒计时)" elif can_change is True: status_text = "可换绑" else: status_text = "未返回换绑倒计时" elif wait_time <= 0: status_text = "可换绑" wait_text = "0" else: status_text = f"剩余 {wait_text}" result = { "act_alias": source_alias, "query_act_alias": source_alias, "query_act_aliases": query_aliases, **snapshot, "change_role_wait_text": wait_text, "bind_ready_for_confirm": bool(snapshot["role_name"]) and not snapshot["is_bound_act"], "bind_confirmed": snapshot["is_bound_act"], "bind_summary": f"act={source_alias or '-'} {self._format_bind_summary(bind_info)}", "bind_candidates": [ { "act_alias": item.get("act_alias"), "role_name": item.get("role_name"), "is_bound_act": self._is_bound_act(item), "change_role_wait_time": self._to_int(item.get("change_role_wait_time")), "can_change_role": item.get("can_change_role"), } for item in candidates ], } self._push_log( "info", f"查询换绑时间 hit={source_alias or '-'} role={role_name} " f"wait={wait_time if wait_time is not None else '-'} can={can_change}", ) self._mark_task( db, task, "success", f"{role_name} {status_text} | act={source_alias 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) result = self._refresh_account_gold_balance(client, account) 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 '-'}", result, ) 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 self._update_task_progress(worker_db, task, "running", "执行中") 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, "refresh_esports_goods": self._execute_refresh_esports_goods, "get_bind_qr": self._execute_get_bind_qr, "confirm_bind": self._execute_confirm_bind, "create_elite_qr": self._execute_create_elite_qr, "prepare_esports_bind": self._execute_prepare_esports_bind, "get_esports_bind_qr": self._execute_get_esports_bind_qr, "query_esports_game_name": self._execute_query_esports_game_name, "confirm_esports_bind": self._execute_confirm_esports_bind, "create_esports_qr": self._execute_create_esports_qr, "query_esports_points": self._execute_query_esports_points, "donate_esports_chicken_gift": self._execute_donate_esports_chicken_gift, "donate_esports_firework_gift": self._execute_donate_esports_firework_gift, "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, "exchange_esports_goods": self._execute_exchange_esports_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_xpd_bind_qr": self._execute_get_xpd_bind_qr, "query_xpd_bind_info": self._execute_query_xpd_bind_info, "confirm_xpd_bind": self._execute_confirm_xpd_bind, "query_xpd_role": self._execute_query_xpd_role, "refresh_xpd_goods": self._execute_refresh_xpd_goods, "query_xpd_balance": self._execute_query_xpd_balance, "query_xpd_fragments": self._execute_query_xpd_fragments, "query_xpd_purchase_records": self._execute_query_xpd_purchase_records, "exchange_xpd_goods": self._execute_exchange_xpd_goods, }.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() for task in tasks: self._push_task_event(task) 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()