Files
yml2213 a64380fbcc feat(account): S6 bind_status 三列拆解 —— 四值枚举、唯一写入口、前端改读账号
`accounts.bind_status` 是个**语义垃圾场**:一个自由文本列,6 个文件、**29 处写入**、
20 多种取值,混了三种东西 —— 真状态(`bind_confirmed`)、动作痕迹(`goods_locked`)、
纯查询痕迹(`points_queried`)。它是**覆盖式**的,所以 `bind_confirmed` 会被之后
任意一次查询盖掉 —— 「这个账号到底绑没绑」根本查不出来。
`esports_bind_status` / `xpd_bind_status` 是同一个病。

## 收敛成四值 + 三列一义

`unknown` / `unbound` / `pending` / `bound`,每个作用域一列:

| 列 | 作用域 |
| --- | --- |
| `accounts.role_bind_status`(**新增**) | 精英宝典角色 |
| `accounts.esports_bind_status`(收敛) | 电竞手册角色 |
| `accounts.xpd_bind_status`(收敛) | 和平小店角色 |

- 新增 `services/account_binding.py`:唯一写入口 `set_bind_status()`,
  传旧自由文本直接 `ValueError`(否则垃圾会被写回去);`map_legacy_bind_status()` 负责旧值映射。
- **29 处自由文本写入全部删除或改走枚举**:真状态走枚举,动作/查询痕迹**不再写状态列**
  (动作已由 S5 的直发事件记录,查询痕迹本来就不该是状态)。
- `accounts.bind_status` 保留原值便于比对,**已无任何写入方**,待 S8 drop。
- 一条**源码扫描用例**禁止任何地方再出现 `account.bind_status = "..."` 这类写入。

## 回填不猜(设计文档 §5.5)

只映射**明确陈述了绑定关系**的取值(`bind_confirmed`/`esports_bound`/`xpd_bound` → bound,
`*_qr_*`/`*_ready` → pending,`game_not_bound`/`xpd_not_bound` → unbound),
**其余一律 unknown**。因为列是覆盖式的,「最后一件事是查询」并不代表没绑定。
迁移 SQL 与 Python 映射有一致性用例:**解析迁移源码里的 CASE 逐条比对**
(已反向验证:故意改错一条映射,用例立刻红)。

## ⚠️ 顺带救回一个差点被我自己丢掉的状态

「**电竞手册是否已开通**」以前**只寄生在 `esports_bind_status` 的 `esports_opened`
取值里**(`elite_opened` 有独立布尔列,电竞没有)。归一化会把那个取值抹成 `unknown`
→ **静默丢状态**。所以:
- 新增 `accounts.esports_opened` 布尔列(与 `elite_opened` 对称),开通任务成功时置位;
- 迁移里**必须先 `save_esports_opened()` 再归一化**,顺序用例钉住;
- 详情页「电竞手册」开通状态改读 `esports_opened`。

## 前端不再从任务日志反推账号状态

工作台原来从**当前加载的那一页任务**里挑每个账号最新的一条来推绑定状态
(`latestConfirmBindTaskByAccount` 等)。两个问题:① 支撑任务一旦翻出窗口,
**显示的状态就是错的**;② 这让任务日志变成「账号状态库」,于是它永远不敢清理。

现在状态读账号字段,任务日志只补「扫码流程中的瞬时信息」(如确认前的
`pending_role_name`)。删掉了两个不再需要的派生与对应的 `clearPending` 绕行逻辑。

## 踩到并修掉的东西

- **模型与迁移不一致**:我把模型收窄成 `String(16)`,迁移却留着 `String(32)` ——
  测试库按模型建、生产按迁移建,两边列宽不同。已让迁移也收窄,且**收窄必须在
  归一化之后**(旧取值最长 19 字符,先收窄会截断成废值)。downgrade 也补齐了反向操作,
  已实测 `downgrade -1 && upgrade head` 干净。
- **parity 用例第一版是抄了一份 SQL**(迁移改了用例也不会红,等于没测)——
  改成解析迁移源码;后来又因为列已收窄、19 字符的旧值塞不进测试库,
  干脆改成源码级解析比对(不造数据)。

## 验证

- `TZ=Asia/Shanghai`:**490 passed / 6 skipped**(S5 后 450 → 净增 40)
- `TZ=UTC`(相关文件)106 passed;迁移链 3 passed;ruff 全绿
- 前端 `tsc -b && vite build` 通过、`npm run lint` **0 errors**
- 真实开发库:迁移重放后三列均为 `varchar(16) NOT NULL DEFAULT 'unknown'`;
  12 个账号的旧值 `elite_status_queried`(纯查询痕迹)→ **unknown**(没有猜成 bound)

未做(后续):S6 的**账号页重构**(列表关键列 / 生命周期主页)保留;
`bind_status` 旧列待 S8 drop;保留策略里「状态派生类查询」现在技术上可下探到 7 天档,
保守起见留待确认(影响的是在用页面显示,不是历史)。
2026-10-04 16:28:06 +08:00

1387 lines
56 KiB
Python
Raw Permalink 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
from .account_binding import (
BIND_BOUND,
BIND_PENDING,
BIND_UNBOUND,
SCOPE_ELITE,
SCOPE_ESPORTS,
set_bind_status,
)
from .business_events import record_bind
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, state: str | None = None
) -> None:
"""写精英角色的**当前值**。
`state` 传 `None` 表示「只更新角色信息,不动绑定状态」—— 纯查询任务
(`query_game_name` 等)就该这样:它们**没有资格**改变绑定状态。
传值时只能是 `account_binding` 的四个枚举。
"""
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")
)
if state is not None:
set_bind_status(account, SCOPE_ELITE, state)
account.updated_at = datetime.now(UTC)
def _apply_esports_bind_info_to_account(
self, account: Account, bind_info: dict, state: str | None = None
) -> 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"))
if state is not None:
set_bind_status(account, SCOPE_ESPORTS, state)
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 min(
bound, key=lambda info: prefer.index(str(info.get("act_alias") or ""))
)
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.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_BOUND if result["bind_confirmed"] else None,
)
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.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_BOUND)
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)
else:
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)
else:
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_BOUND)
else:
account.updated_at = datetime.now(UTC)
# 确认接口成功了,但回查抛异常 —— 没能亲眼看到绑定生效。
# 仍然记(确认请求确实发出且成功),但 weak=True 标出来。
record_bind(
db,
account,
task_id=task.id,
scope="elite",
role_name=role_name,
act_alias=use_confirm_alias,
rebind=_bind_is_rebind(before_snapshot),
weak=True,
)
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_BOUND)
# 强事实:确认接口成功 + 回查命中已生效绑定。
# ⚠️ 上面还有一条 success 是「检测到账号**本来就已经绑定**」(什么都没做),
# 那条**不发事件** —— 否则用户每点一次「确认绑定」时间线就多一条假的绑定记录。
record_bind(
db,
account,
task_id=task.id,
scope="elite",
role_name=final_role_name,
act_alias=use_confirm_alias,
rebind=_bind_is_rebind(after_snapshot),
)
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,
BIND_BOUND
if esports_bound
else (BIND_PENDING if role_text else BIND_UNBOUND),
)
# 二维码用于重新选择角色,不应因为已有角色或换绑冷却而隐藏。
# 冷却是否允许最终由 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.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.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, BIND_PENDING)
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, BIND_BOUND)
# 业务事实在这一刻成立:确认接口成功 + 回查 esports_bound 为真。
# 必须在 _mark_task 之前发 —— 它内部 commit,而 emit 只 RELEASE SAVEPOINT、
# 不提交外层事务,runner 的 finally 只 close(未提交即回滚),之后发会被静默丢弃。
role_text = self._esports_role_text(after_state) or ""
record_bind(
db,
account,
task_id=task.id,
scope="esports",
role_name=role_text,
act_alias=act_alias,
)
self._mark_task(
db,
task,
"success",
f"电竞手册绑定成功: {role_text 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,
BIND_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)
else:
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.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,
)
def _bind_is_rebind(snapshot: dict) -> bool:
"""这次是「换绑」还是「首次绑定」。
`before_bound` 命中说明确认前**该账号已有一个生效绑定**,本次是把角色换掉;
否则是首次绑定。旧收集器不区分,时间线上看不出「换绑」这件事。
"""
return bool(
snapshot.get("bound_act")
or snapshot.get("bind_confirmed")
or snapshot.get("role_name")
)