Files
live-hub-py/web/backend/services/douyu_runner_bind.py
T

1336 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""斗鱼任务执行器:角色绑定(由 douyu_runner.py 按功能域拆分)。"""
from __future__ import annotations
import time
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient, DouyuActivityError
from ..models import Account, DouyuTask
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(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(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(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。
活动 alias20260120QYOOB)只反映当前已绑定;
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(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(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(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(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(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(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(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(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(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,
)