"""斗鱼任务执行器:角色绑定(由 douyu_runner.py 按功能域拆分)。""" from __future__ import annotations import time from datetime import datetime, timezone from typing import Any, TYPE_CHECKING from sqlalchemy.orm import Session from core.douyu import DouyuActivityClient, DouyuActivityError from ..models import Account, DouyuTask if TYPE_CHECKING: from .douyu_runner import DouyuBatchRunner DOUYU_LEGACY_BIND_ACT_ALIAS = "20250213NQCYX" DOUYU_BIND_ROLE_POLL_SECONDS = 65 DOUYU_BIND_ROLE_POLL_INTERVAL = 5 DOUYU_CONFIRM_EFFECT_POLL_TIMES = 3 DOUYU_CONFIRM_EFFECT_POLL_INTERVAL = 3 class BindMixin: """游戏角色绑定域:绑定状态机、扫码/确认绑定、换绑冷却。""" if TYPE_CHECKING: _stop: Any payload: dict @staticmethod def _to_int(value: Any) -> int | None: ... @staticmethod def _format_wait_time(seconds: int | None) -> str: ... def _push_log(self, level: str, message: str) -> None: ... def _mark_task(self, *args: Any, **kwargs: Any) -> None: ... def _update_task_progress(self, *args: Any, **kwargs: Any) -> None: ... def _client(self, cookie: str) -> DouyuActivityClient: ... @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 _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_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) cooldown = cooldown_info or {} 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.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.get('can_change_role')}", ) result = { "act_alias": qr_act_alias or cooldown.get("act_alias") or before.get("act_alias"), "query_act_alias": cooldown.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 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 and before_bound is not None: 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_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_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, )