diff --git a/docs/设计-账号全生命周期.md b/docs/设计-账号全生命周期.md
index 05e02f8..b742fc4 100644
--- a/docs/设计-账号全生命周期.md
+++ b/docs/设计-账号全生命周期.md
@@ -301,16 +301,57 @@ CREATE INDEX ix_account_events_ledger
### 3.6 `bind_status` 的拆解(旧列 → 新字段)
-| 旧取值族 | 新归属 |
-| --- | --- |
-| `bind_confirmed` / `bind_qr_generated` / `game_queried` / `change_time_queried` / `game_not_bound` | `accounts.role_bind_status`(**明确的当前值**:`unbound`/`bound`/`pending`)
动作痕迹进 `account_events`,不占状态列 |
-| `gold_qr_created` / `gold_recharged` / `gold_balance_queried` | 充值流水进 `account_events`;余额进 `gold_balance` |
-| `elite_qr_created` / `elite_opened` / `elite_status_queried` | `elite_opened` + `elite_expire_time` 已有;开通时间进 `account_events.elite_opened` |
-| `esports_*` | `accounts.esports_bind_status` 收敛为明确值;其余进事件流 |
-| `xpd_*` | 同上 |
-| `points_queried` / `limited_goods_queried` / `exchange_records_queried` / `csrf_token_ready` / `goods_refreshed` | **直接删除** —— 这些是「跑过一个查询任务」,不是账号状态 |
+> **S6 已落地(2026-10-04)**,实现见 `web/backend/services/account_binding.py`,
+> 迁移 `20261004_0044`。
-**规则**:状态列只放「现在是什么」,动作痕迹一律进 `account_events`(要查随时能查,且不会被覆盖)。
+**它烂掉的方式**:一个自由文本列,**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`(收敛) | 和平小店角色绑定 |
+
+**写入收口**:只有 `account_binding.set_bind_status()` 能写;
+传旧自由文本直接 `ValueError`(否则垃圾又会被写回去)。
+有一条**源码扫描用例**禁止任何地方再出现自由文本写入。
+
+**回填不猜**(§5.5):只映射明确陈述绑定关系的取值
+(`bind_confirmed`/`esports_bound`/`xpd_bound` → `bound`,`*_qr_*`/`*_ready` → `pending`,
+`game_not_bound`/`xpd_not_bound` → `unbound`),**其余一律 `unknown`**。
+迁移的 CASE 与 Python 映射有一致性用例(解析迁移 SQL 源码比对)。
+
+**动作与查询痕迹一律不再写状态列**:动作已由 S5 的直发事件记录,
+查询痕迹本来就不该是状态。
+
+#### S6 顺带救回一个差点丢掉的状态
+
+「**电竞手册是否已开通**」以前**只寄生在 `esports_bind_status` 的 `esports_opened`
+取值里**(`elite_opened` 有独立布尔列,电竞没有)。归一化会把那个取值抹成 `unknown`
+—— 于是迁移里必须先 `save_esports_opened()` 把它救进新增的 `accounts.esports_opened`
+列,**再**归一化。顺序用例钉住了这一点(`upgrade()` 里 `save_esports_opened`
+必须早于 `normalise_esports_bind_status`)。
+
+> `accounts.bind_status` 列**保留原值不动**(便于人工比对),但已无任何写入方,
+> 计划 S8 一并 drop。`role_bind_status`/`esports_bind_status`/`xpd_bind_status`
+> 三列收窄成 `String(16)` + `server_default='unknown'`,与模型一致;
+> ⚠️ 收窄**必须放在归一化之后**(旧取值最长 19 字符,先收窄会截断成废值)。
+
+#### 前端不再从任务日志反推账号状态
+
+工作台原来从**当前加载的任务列表**里挑每个账号最新的一条来推绑定状态
+(`latestConfirmBindTaskByAccount` 等)—— 只看得见这一页的任务,支撑任务一旦翻出
+窗口显示的状态就是**错的**;而且这让任务日志变成了「账号状态库」,于是它永远不敢清理。
+
+现在**状态读账号字段**,任务日志只用来补「扫码流程中的瞬时信息」
+(如扫码后、确认前的 `pending_role_name`)。
### 3.7 `login_tasks` 瘦身:退化为登录尝试历史
@@ -353,6 +394,10 @@ CREATE INDEX ix_account_events_ledger
> 这是历史包袱(与 `bind_status` 同源:账号的「现在是什么」存在执行日志里),
> §3.6 的状态列拆解才是根治;在拆解完成前,这类查询保守留长。
>
+> **S6 之后**:前端已不再用任务日志推账号状态,因此「状态派生依赖」那几类查询
+> **理论上可以下探到 7 天档**(只剩扫码流程的瞬时提示会看不到)。这一步保守起见
+> 留待确认后再做 —— 它影响的是在用页面的显示,不是历史。
+>
> `account_events` 独立长期保留(体量小:每天数百条,一年 10 万条以内)。
> 注:`DELETE` 只让 InnoDB 的空间**可复用**,文件不会自动缩 —— 要缩需在维护窗口
> `OPTIMIZE TABLE`。
@@ -463,7 +508,7 @@ WHERE a.cookie <> src.cookie;
| **S3** ✅ | 状态收口(**已完成 2026-10-04**):`account_lifecycle` 提供 `derive_status()` / `sync_status()` / `set_manual_state()` / `on_login_success()`;9 处判据写入点全部接线;6 状态状态机上线;自动化选行排除 `disabled`/`archived`;新增 `account:manage` 权限与停用/归档/恢复接口;前端列表加状态列与筛选、详情页加状态卡与操作入口 | `status` 可信 | S2 |
| **S4** ✅ | 事件发射点补齐(**已完成 2026-10-04**):`assigned` / `tag_changed` 接上(此前定义了却从没发过)、新增 `ck_cleared`、`ck_invalid` 改为只在转变时发;`audit_logs.account_id` 在分配/标签/检测/清 CK 处打通;新增 `emit_bulk`(批量操作逐账号写事件)与 `record_account_action`(审计+事件一次写完);时间线支持 `category` 筛选;前端补 `status_changed` / `ck_cleared` 样式与分类筛选;补上改备注接口 `PUT /api/accounts/{id}/remark`(`remark_changed` 从此有发射点)| 时间线完整 | S2 |
| **S5** ✅ | 业务事实直发事件(**已完成 2026-10-04**):新增 `services/business_events.py` 命名构造函数(`record_recharge` / `record_exchange` / `record_points_credit` / `record_bind`);9 处成功点直发(含**此前从未收录**的电竞兑换、和平小店兑换、锁单支付、电竞礼物积分);修掉「到账判据 `!=`」与「回调晚于超时」两个洞;幂等键 `douyu_task:{id}` 让直发与旧收集器/回调互相去重;**删除收集器**(含映射、水位线、测试,迁移 `0043` 清作业行);新增 `douyu_tasks` 保留策略作业(噪声 7 天 / 其余 90 天,默认关闭) | 台账可用,687MB 可控 | S4 |
-| **S6** | 账号页重构:列表加状态与关键列;详情页做成「生命周期主页」(状态卡 + 台账 + 时间线 + 操作);`bind_status` 三列拆解落地 | 账号管理真正独立 | S3、S5 |
+| **S6** ◐ | `bind_status` 三列拆解(**已完成 2026-10-04**):新增 `services/account_binding.py`(四值枚举 + 唯一写入口 + 旧值映射)、迁移 `0044`(新增 `role_bind_status` 与 `esports_opened`、归一化三列、收窄列宽)、删除全部 29 处自由文本写入、前端改为读账号字段(不再从任务日志反推状态)、详情页显示三列绑定状态。**账号页重构(列表关键列 / 生命周期主页)尚未做**,`bind_status` 旧列待 S8 drop | 账号管理真正独立 | S3、S5 |
| **S7** | 账号检测并入账号域(`account-check` 从「粘贴文本」改为「按账号选」,结果落事件) | 检测不再是孤岛 | S6 |
| **S8** | 清理:drop `login_tasks` 旧列与 `accounts` 三个 `*_bind_status` 列(**不可逆,前置:每日备份 + 反向回填脚本**) | 包袱丢干净 | S6 |
diff --git a/tests/test_account_binding.py b/tests/test_account_binding.py
new file mode 100644
index 0000000..8cb35ce
--- /dev/null
+++ b/tests/test_account_binding.py
@@ -0,0 +1,320 @@
+"""S6:角色绑定状态三列拆解。
+
+守住四件事:
+
+1. **枚举收口**:三个绑定状态列只能有 `unknown/unbound/pending/bound` 四个值,
+ 传旧自由文本直接报错(否则垃圾又会被写回去);
+2. **不猜**:旧自由文本 → 新枚举只映射**明确陈述了绑定关系**的取值,
+ 其余一律 `unknown`(`bind_status` 是覆盖式的,「最后一件事是查询」不代表没绑定);
+3. **迁移与运行时同规则**:迁移 `0044` 的 CASE 必须与 `LEGACY_*_BIND_MAP` 一致;
+4. **写入收口**:源码里不允许再出现 `account.bind_status = "..."` 这类自由文本写入
+ (这正是该列烂掉的方式)。
+"""
+
+from datetime import UTC, datetime
+from typing import ClassVar
+
+import pytest
+
+from tests.support.db import get_test_engine, make_test_session
+from web.backend.models import Account
+from web.backend.services import account_binding as binding
+
+NOW = datetime(2026, 10, 4, 12, 0, 0, tzinfo=UTC)
+
+
+class _Base:
+ def setup_method(self):
+ self.engine = get_test_engine()
+ self.session = make_test_session(self.engine)
+
+ def teardown_method(self):
+ self.session.close()
+
+ def account(self, **kwargs) -> Account:
+ acc = Account(
+ username="bind-acc",
+ password="p",
+ email="b@e.com",
+ email_password="ep",
+ **kwargs,
+ )
+ self.session.add(acc)
+ self.session.commit()
+ return acc
+
+
+class TestEnum(_Base):
+ def test_only_four_states(self):
+ assert set(binding.ALL_BIND_STATES) == {
+ "unknown",
+ "unbound",
+ "pending",
+ "bound",
+ }
+
+ def test_labels_cover_every_state(self):
+ assert set(binding.BIND_LABELS) == set(binding.ALL_BIND_STATES)
+
+ def test_set_bind_status_writes_and_reports_change(self):
+ acc = self.account()
+ assert (
+ binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_BOUND)
+ is True
+ )
+ assert acc.role_bind_status == "bound"
+ # 幂等:同值重复设置不算变化
+ assert (
+ binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_BOUND)
+ is False
+ )
+ assert (
+ binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_UNBOUND)
+ is True
+ )
+
+ @pytest.mark.parametrize(
+ "legacy",
+ [
+ "bind_confirmed", # 旧自由文本
+ "game_queried",
+ "elite_opened",
+ "points_queried",
+ "",
+ "BOUND",
+ ],
+ )
+ def test_set_bind_status_rejects_free_text(self, legacy):
+ """传旧自由文本必须**报错**,不能静默写进去 —— 那是垃圾场重建的路径。"""
+ acc = self.account()
+ with pytest.raises(ValueError):
+ binding.set_bind_status(acc, binding.SCOPE_ELITE, legacy)
+ assert acc.role_bind_status == "unknown"
+
+ def test_each_scope_maps_to_its_own_column(self):
+ acc = self.account()
+ binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_BOUND)
+ binding.set_bind_status(acc, binding.SCOPE_ESPORTS, binding.BIND_PENDING)
+ binding.set_bind_status(acc, binding.SCOPE_PEACE, binding.BIND_UNBOUND)
+ assert (
+ acc.role_bind_status,
+ acc.esports_bind_status,
+ acc.xpd_bind_status,
+ ) == ("bound", "pending", "unbound")
+ # 三列互不覆盖(旧设计里它们会互相盖掉)
+ assert binding.get_bind_status(acc, binding.SCOPE_ELITE) == "bound"
+
+ def test_unknown_scope_is_rejected(self):
+ acc = self.account()
+ with pytest.raises(ValueError):
+ binding.set_bind_status(acc, "nope", binding.BIND_BOUND)
+
+ def test_bind_payload_carries_state_and_label(self):
+ acc = self.account()
+ binding.set_bind_status(acc, binding.SCOPE_ELITE, binding.BIND_BOUND)
+ payload = binding.bind_payload(acc)
+ assert payload["elite_bind_status"] == "bound"
+ assert payload["elite_bind_label"] == "已绑定"
+ assert payload["esports_bind_status"] == "unknown"
+ assert payload["peace_bind_label"] == "未知"
+ assert set(payload) == {
+ f"{scope}_{suffix}"
+ for scope in binding.ALL_SCOPES
+ for suffix in ("bind_status", "bind_label")
+ }
+
+
+class TestLegacyMapping:
+ """只映射明确陈述绑定关系的取值,其余 unknown。"""
+
+ @pytest.mark.parametrize(
+ ("scope", "legacy", "expected"),
+ [
+ # 明确陈述
+ (binding.SCOPE_ELITE, "bind_confirmed", "bound"),
+ (binding.SCOPE_ELITE, "game_not_bound", "unbound"),
+ (binding.SCOPE_ELITE, "bind_qr_generated", "pending"),
+ (binding.SCOPE_ESPORTS, "esports_bound", "bound"),
+ (binding.SCOPE_ESPORTS, "esports_bind_ready", "pending"),
+ (binding.SCOPE_ESPORTS, "esports_bind_confirming", "pending"),
+ (binding.SCOPE_ESPORTS, "esports_qr_created", "pending"),
+ (binding.SCOPE_ESPORTS, "esports_role_switching", "pending"),
+ (binding.SCOPE_PEACE, "xpd_bound", "bound"),
+ (binding.SCOPE_PEACE, "xpd_not_bound", "unbound"),
+ (binding.SCOPE_PEACE, "xpd_bind_qr_ready", "pending"),
+ # 什么都没说 → unknown(不猜)
+ (binding.SCOPE_ELITE, "game_queried", "unknown"),
+ (binding.SCOPE_ELITE, "change_time_queried", "unknown"),
+ (binding.SCOPE_ELITE, "elite_opened", "unknown"),
+ (binding.SCOPE_ELITE, "gold_recharged", "unknown"),
+ (binding.SCOPE_ELITE, "goods_locked", "unknown"),
+ (binding.SCOPE_ELITE, "csrf_token_ready", "unknown"),
+ (binding.SCOPE_ESPORTS, "esports_opened", "unknown"),
+ (binding.SCOPE_ESPORTS, "esports_points_queried", "unknown"),
+ (binding.SCOPE_PEACE, "xpd_goods_refreshed", "unknown"),
+ (binding.SCOPE_PEACE, "xpd_balance_queried", "unknown"),
+ # 空值
+ (binding.SCOPE_ELITE, "", "unknown"),
+ (binding.SCOPE_ELITE, None, "unknown"),
+ ],
+ )
+ def test_mapping(self, scope, legacy, expected):
+ assert binding.map_legacy_bind_status(scope, legacy) == expected
+
+ def test_every_mapped_target_is_a_valid_state(self):
+ for scope, table in binding.LEGACY_MAPS.items():
+ for legacy, state in table.items():
+ assert state in binding.ALL_BIND_STATES, f"{scope}:{legacy}"
+
+
+class TestMigrationMatchesRuntime(_Base):
+ """迁移 `0044` 的 CASE 必须与 `LEGACY_*_BIND_MAP` 逐字对应。
+
+ 分叉了不会报错,只会表现为「迁移刚跑完是 A、下一次任务又改成 B」。
+ 这里把迁移里那三段 UPDATE 真跑一遍,与 Python 映射逐条比对。
+ """
+
+ def _load_migration(self):
+ import importlib.util
+ from pathlib import Path
+
+ path = (
+ Path(__file__).resolve().parents[1]
+ / "web/backend/migrations/versions/20261004_0044_bind_status_split.py"
+ )
+ spec = importlib.util.spec_from_file_location("mig_0044_bind_split", path)
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+ @staticmethod
+ def _equals_pairs(sql: str, column: str) -> dict[str, str]:
+ import re
+
+ return dict(re.findall(rf"WHEN {column} = '([a-z_]+)' THEN '([a-z_]+)'", sql))
+
+ @staticmethod
+ def _in_branch(sql: str, column: str) -> tuple[set[str], str]:
+ """解析 `WHEN col IN ( ... ) THEN 'x'`(列表跨多行)。"""
+ import re
+
+ match = re.search(
+ rf"WHEN {column} IN \((.*?)\)\s*THEN '([a-z_]+)'", sql, re.DOTALL
+ )
+ if not match:
+ return set(), ""
+ values = set(re.findall(r"'([a-z_]+)'", match.group(1)))
+ return values, match.group(2)
+
+ def test_migration_sql_agrees_with_python_maps(self):
+ """迁移里的 CASE 必须与 `LEGACY_*_BIND_MAP` 逐条对应。
+
+ 做法是**解析迁移自己的 SQL 源码**(不是抄一份,也不是造数据跑):
+ 旧取值最长 19 字符(`esports_bind_ready`),而列已收窄成 `String(16)`,
+ 已经塞不进测试库了 —— 恰好说明「收窄必须放在归一化之后」是对的。
+ """
+ import inspect
+
+ migration = self._load_migration()
+
+ # 精英:全部是 WHEN col = 'x' THEN 'y'
+ role_sql = inspect.getsource(migration.backfill_role_bind_status)
+ assert (
+ self._equals_pairs(role_sql, "bind_status") == binding.LEGACY_ROLE_BIND_MAP
+ )
+
+ # 电竞:等值分支 + 一个 IN 分支(都归 pending)
+ esports_sql = inspect.getsource(migration.normalise_esports_bind_status)
+ esports_map = self._equals_pairs(esports_sql, "esports_bind_status")
+ in_values, in_target = self._in_branch(esports_sql, "esports_bind_status")
+ assert in_target in binding.ALL_BIND_STATES
+ for value in in_values:
+ esports_map[value] = in_target
+ assert esports_map == binding.LEGACY_ESPORTS_BIND_MAP
+
+ # 小店:全部是等值分支
+ xpd_sql = inspect.getsource(migration.normalise_xpd_bind_status)
+ assert (
+ self._equals_pairs(xpd_sql, "xpd_bind_status")
+ == binding.LEGACY_XPD_BIND_MAP
+ )
+
+ def test_every_sql_branch_target_is_a_valid_state(self):
+ """迁移不可能回填出枚举以外的值(否则运行时 get_bind_status 会兜成 unknown)。"""
+ import inspect
+ import re
+
+ migration = self._load_migration()
+ for func in (
+ migration.backfill_role_bind_status,
+ migration.normalise_esports_bind_status,
+ migration.normalise_xpd_bind_status,
+ ):
+ targets = set(re.findall(r"THEN '([a-z_]+)'", inspect.getsource(func)))
+ assert targets, f"{func.__name__} 里没解析到 THEN 分支"
+ assert targets <= set(binding.ALL_BIND_STATES), targets
+
+ def test_migration_saves_esports_opened_before_normalising(self):
+ """`upgrade()` 里必须先救 `esports_opened`、再归一化。
+
+ 「电竞手册是否已开通」以前只寄生在 `esports_bind_status` 的 `esports_opened`
+ 取值里,归一化会把它抹成 `unknown` —— **顺序反了就静默丢状态**。
+ """
+ import inspect
+
+ migration = self._load_migration()
+ source = inspect.getsource(migration.upgrade)
+ save_pos = source.index("save_esports_opened")
+ normalise_pos = source.index("normalise_esports_bind_status")
+ assert save_pos < normalise_pos, (
+ "必须先救 esports_opened,再归一化 esports_bind_status"
+ )
+
+
+class TestNoFreeTextWriters:
+ """源码扫描:不允许再有人往绑定状态列写自由文本。
+
+ `bind_status` 就是这么烂掉的 —— 6 个文件、29 处写入、20 多种取值,
+ 状态/动作/查询三种语义混在一个覆盖式列里。
+ """
+
+ ALLOWED: ClassVar[frozenset[str]] = frozenset(
+ {
+ "web/backend/models.py",
+ "web/backend/services/account_binding.py",
+ }
+ )
+
+ def test_no_direct_bind_status_assignment(self):
+ import re
+ from pathlib import Path
+
+ root = Path(__file__).resolve().parents[1]
+ backend = root / "web" / "backend"
+ patterns = (
+ re.compile(
+ r"\.(role_bind_status|esports_bind_status|xpd_bind_status)\s*=(?!=)"
+ ),
+ # 旧的垃圾场列:任何人都不能再写
+ re.compile(r"\bbind_status\s*=(?!=)"),
+ )
+ offenders: list[str] = []
+ for path in backend.rglob("*.py"):
+ rel = path.relative_to(root).as_posix()
+ if rel in self.ALLOWED or "/migrations/" in rel or "huya" in rel:
+ continue
+ for lineno, line in enumerate(
+ path.read_text(encoding="utf-8").splitlines(), 1
+ ):
+ stripped = line.lstrip()
+ if stripped.startswith("#"):
+ continue
+ # 过滤掉字典键("bind_status": ...)与比较
+ if any(p.search(line) for p in patterns):
+ offenders.append(f"{rel}:{lineno}: {stripped}")
+
+ assert not offenders, (
+ "绑定状态只能由 account_binding.set_bind_status 写;"
+ "旧 bind_status 列不允许任何写入方:\n" + "\n".join(offenders)
+ )
diff --git a/tests/test_douyu_elite_open_flow.py b/tests/test_douyu_elite_open_flow.py
index 3aca3fd..dd1273d 100644
--- a/tests/test_douyu_elite_open_flow.py
+++ b/tests/test_douyu_elite_open_flow.py
@@ -137,7 +137,11 @@ class TestEliteHandbookOpenFlow:
assert task.result["elite_already_opened"] is True
assert "pay_url" not in task.result
client.create_elite_qr.assert_not_called()
- assert self.account.bind_status == "elite_opened"
+ # 开通与否看**状态字段**,不看 bind_status。
+ # `bind_status` 曾把「最后发生的动作」当状态写(`elite_opened` / `points_queried` …),
+ # 覆盖式写入导致查不出「到底开通没有」。S6 起该列不再有任何写入方。
+ assert self.account.elite_opened is True
+ assert self.account.bind_status != "elite_opened"
def test_create_elite_qr_uses_points_baseline(self, monkeypatch):
"""账上已有 680 积分时不能立刻判成功,必须等积分真的增长。"""
diff --git a/tests/test_douyu_gold_recharge_channel.py b/tests/test_douyu_gold_recharge_channel.py
index d7ef7bb..47aacef 100644
--- a/tests/test_douyu_gold_recharge_channel.py
+++ b/tests/test_douyu_gold_recharge_channel.py
@@ -265,7 +265,19 @@ class TestDouyuGoldRechargeChannel:
assert self.task.status == "success"
assert self.task.message == "供应商直充成功(异步通知)"
self.session.refresh(self.account)
- assert self.account.bind_status == "gold_recharged"
+ # 回调到账的事实现在进**账号时间线**(S5 直发事件),不再写 bind_status。
+ from web.backend.models import AccountEvent
+
+ events = (
+ self.session.query(AccountEvent)
+ .filter(
+ AccountEvent.account_id == self.account.id,
+ AccountEvent.event_type == "recharge",
+ )
+ .all()
+ )
+ assert len(events) == 1, "回调成功必须补一条充值事件"
+ assert events[0].detail["channel"] == "supplier_api"
task_result = self.task.result
assert isinstance(task_result, dict)
assert task_result["order_id"] == "supplier-001"
diff --git a/tests/test_douyu_write_path_contracts.py b/tests/test_douyu_write_path_contracts.py
index f8661e5..ba957f3 100644
--- a/tests/test_douyu_write_path_contracts.py
+++ b/tests/test_douyu_write_path_contracts.py
@@ -71,7 +71,9 @@ class TestDouyuWritePathContracts:
self.session.refresh(self.account)
assert task.status == "success"
assert task.result["order_id"] == "5874"
- assert self.account.bind_status == "goods_locked"
+ # 锁单是**流程痕迹**,不是账号状态 —— S6 起不再写 bind_status
+ # (动作本身在 douyu_tasks 与 account_events 里都能查)。
+ assert self.account.bind_status != "goods_locked"
client.create_exchange_order.assert_called_once()
client.pay_exchange_order.assert_not_called()
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index dee9120..d254601 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TESTS_DIR.parent
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
-HEAD_REVISION = "20261004_0043"
+HEAD_REVISION = "20261004_0044"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
diff --git a/web/backend/migrations/versions/20261004_0044_bind_status_split.py b/web/backend/migrations/versions/20261004_0044_bind_status_split.py
new file mode 100644
index 0000000..151eb97
--- /dev/null
+++ b/web/backend/migrations/versions/20261004_0044_bind_status_split.py
@@ -0,0 +1,177 @@
+"""S6:`bind_status` 三列拆解
+
+把三个语义混杂的自由文本列收敛成明确枚举:
+
+| 列 | 作用域 | 取值 |
+| --- | --- | --- |
+| `accounts.role_bind_status`(**新增**) | 精英宝典角色 | unknown / unbound / pending / bound |
+| `accounts.esports_bind_status`(收敛) | 电竞手册角色 | 同上 |
+| `accounts.xpd_bind_status`(收敛) | 和平小店角色 | 同上 |
+
+旧的 `accounts.bind_status` **不动**(保留原值便于人工比对,计划 S8 再 drop),
+但代码里所有写入方在本次一并删除。
+
+## 回填原则(设计文档 §5.5):不猜测
+
+旧值是**覆盖式**的 —— `bind_confirmed` 会被之后任意一次查询
+(例如 `limited_goods_queried`)盖掉,所以「最后一件事是查询」并不代表账号没绑定。
+因此只映射**明确陈述了绑定关系**的取值,其余一律 `unknown`:
+
+- `bind_confirmed` / `esports_bound` / `xpd_bound` → `bound`
+ (确认成功那一刻写的,而绑定不会被之后的查询撤销)
+- `bind_qr_generated` / `esports_bind_ready` / `esports_bind_confirming` /
+ `esports_qr_created` / `esports_role_switching` / `xpd_bind_qr_ready` → `pending`
+- `game_not_bound` / `xpd_not_bound` → `unbound`
+- **其余全部 `unknown`**(含 `game_queried` / `points_queried` / `goods_locked` 等)
+
+⚠️ 下面的 CASE **必须与 `services/account_binding.py` 里的
+`LEGACY_*_BIND_MAP` 逐字对应**,有一条用例真跑这段 SQL 与它们比对。
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20261004_0044"
+down_revision: str | None = "20261004_0043"
+branch_labels: str | None = None
+depends_on: str | None = None
+
+COLUMN = "role_bind_status"
+
+
+def save_esports_opened(bind) -> None:
+ """先把「电竞手册已开通」救到独立列。
+
+ ⚠️ **必须在归一化 `esports_bind_status` 之前跑** —— 那个状态以前只寄生在
+ 该列的 `esports_opened` 取值里,归一化会把它抹成 `unknown`,**静默丢状态**。
+ """
+ bind.execute(
+ sa.text(
+ """
+ UPDATE accounts
+ SET esports_opened = (esports_bind_status = 'esports_opened')
+ """
+ )
+ )
+
+
+def backfill_role_bind_status(bind) -> None:
+ """精英:从旧的自由文本列回填(只映射明确陈述绑定关系的取值)。"""
+ bind.execute(
+ sa.text(
+ """
+ UPDATE accounts
+ SET role_bind_status = CASE
+ WHEN bind_status = 'bind_confirmed' THEN 'bound'
+ WHEN bind_status = 'bind_qr_generated' THEN 'pending'
+ WHEN bind_status = 'game_not_bound' THEN 'unbound'
+ ELSE 'unknown'
+ END
+ """
+ )
+ )
+
+
+def normalise_esports_bind_status(bind) -> None:
+ bind.execute(
+ sa.text(
+ """
+ UPDATE accounts
+ SET esports_bind_status = CASE
+ WHEN esports_bind_status = 'esports_bound' THEN 'bound'
+ WHEN esports_bind_status IN (
+ 'esports_bind_ready',
+ 'esports_bind_confirming',
+ 'esports_qr_created',
+ 'esports_role_switching'
+ ) THEN 'pending'
+ ELSE 'unknown'
+ END
+ """
+ )
+ )
+
+
+def normalise_xpd_bind_status(bind) -> None:
+ bind.execute(
+ sa.text(
+ """
+ UPDATE accounts
+ SET xpd_bind_status = CASE
+ WHEN xpd_bind_status = 'xpd_bound' THEN 'bound'
+ WHEN xpd_bind_status = 'xpd_bind_qr_ready' THEN 'pending'
+ WHEN xpd_bind_status = 'xpd_not_bound' THEN 'unbound'
+ ELSE 'unknown'
+ END
+ """
+ )
+ )
+
+
+def upgrade() -> None:
+ op.add_column(
+ "accounts",
+ sa.Column(
+ COLUMN,
+ sa.String(length=16),
+ nullable=False,
+ server_default="unknown",
+ comment="精英宝典角色绑定:unknown/unbound/pending/bound",
+ ),
+ )
+ # 「电竞手册是否已开通」以前**只寄生在 esports_bind_status 里**(取值
+ # `esports_opened`)。归一化会把那个取值抹掉,所以必须先把它救到独立列里,
+ # **否则这一步会静默丢掉「已开通」这个状态**。
+ op.add_column(
+ "accounts",
+ sa.Column(
+ "esports_opened",
+ sa.Boolean(),
+ nullable=True,
+ comment="电竞手册是否已开通(与 elite_opened 对称)",
+ ),
+ )
+ bind = op.get_bind()
+ # 顺序不能换:先救 esports_opened,再归一化
+ save_esports_opened(bind)
+ backfill_role_bind_status(bind)
+ normalise_esports_bind_status(bind)
+ normalise_xpd_bind_status(bind)
+
+ # 归一化之后把两列收窄成与模型一致的 `String(16)` + 服务器默认值。
+ # ⚠️ 必须放在归一化**之后**:旧取值最长 19 字符(`esports_bind_ready`),
+ # 先收窄会把它们截断成一堆废值。
+ for column in ("esports_bind_status", "xpd_bind_status"):
+ op.alter_column(
+ "accounts",
+ column,
+ existing_type=sa.String(length=32),
+ type_=sa.String(length=16),
+ existing_nullable=False,
+ existing_server_default=None,
+ server_default="unknown",
+ comment="角色绑定:unknown/unbound/pending/bound",
+ )
+
+
+def downgrade() -> None:
+ """回退只摘掉新列。
+
+ 刻意**不还原**电竞/小店的旧自由文本取值:那些值(`esports_points_queried` 之类)
+ 已经和信息量更大的动作痕迹一起废弃了,编不回去;而 `bind_status` 原列一直没动,
+ 真要排查历史可以直接看它。
+ """
+ for column in ("esports_bind_status", "xpd_bind_status"):
+ op.alter_column(
+ "accounts",
+ column,
+ existing_type=sa.String(length=16),
+ type_=sa.String(length=32),
+ existing_nullable=False,
+ existing_server_default="unknown",
+ server_default=None,
+ )
+ op.drop_column("accounts", "esports_opened")
+ op.drop_column("accounts", COLUMN)
diff --git a/web/backend/models.py b/web/backend/models.py
index 847e4ec..0874b53 100644
--- a/web/backend/models.py
+++ b/web/backend/models.py
@@ -96,7 +96,19 @@ class Account(Base):
game_channel: Mapped[str] = mapped_column(String(128), default="")
gold_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
exchange_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ # ⚠️ 已废弃的「垃圾场」列:曾混杂状态/动作/查询三种语义,覆盖式写入导致
+ # 「到底绑没绑」查不出来。S6 起**不再有任何写入方**,绑定状态走下面三列。
+ # 计划在 S8 连同其他历史包袱一起 drop(需先做备份)。
bind_status: Mapped[str] = mapped_column(String(32), default="")
+ # 精英宝典角色绑定:unknown / unbound / pending / bound(见 services/account_binding)
+ role_bind_status: Mapped[str] = mapped_column(
+ String(16), default="unknown", server_default="unknown"
+ )
+ # 电竞手册是否已开通(与 elite_opened 对称)。
+ # 以前这个状态**只寄生在 `esports_bind_status` 里**(取值 `esports_opened`)——
+ # 那是「一列多义」的典型:同一个列既表示开通、又表示角色绑定、又表示查询痕迹。
+ # S6 拆解时差点把这个信息丢掉,所以补成独立布尔列。
+ esports_opened: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
change_role_wait_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 精英宝典开通状态(getUserInfo 的 manualType / expireTime)。
# 光看积分区分不了「开通后积分花完」和「从未开通」,必须单独落库。
@@ -105,7 +117,9 @@ class Account(Base):
esports_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
esports_game_name: Mapped[str] = mapped_column(String(128), default="")
esports_game_channel: Mapped[str] = mapped_column(String(128), default="")
- esports_bind_status: Mapped[str] = mapped_column(String(32), default="")
+ esports_bind_status: Mapped[str] = mapped_column(
+ String(16), default="unknown", server_default="unknown"
+ )
esports_change_role_wait_time: Mapped[int | None] = mapped_column(
Integer, nullable=True
)
@@ -121,7 +135,9 @@ class Account(Base):
xpd_area_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
xpd_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
xpd_fragments: Mapped[int | None] = mapped_column(Integer, nullable=True)
- xpd_bind_status: Mapped[str] = mapped_column(String(32), default="")
+ xpd_bind_status: Mapped[str] = mapped_column(
+ String(16), default="unknown", server_default="unknown"
+ )
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py
index a09dca3..62467aa 100644
--- a/web/backend/routers/accounts.py
+++ b/web/backend/routers/accounts.py
@@ -30,6 +30,7 @@ from ..schemas import (
AccountTag,
BatchAssign,
)
+from ..services.account_binding import bind_payload
from ..services.account_events_service import (
CATEGORY_OPS,
EVENT_ASSIGNED,
@@ -885,7 +886,7 @@ def account_overview(
"exchange_balance": acc.exchange_balance,
"game_name": acc.game_name,
"game_channel": acc.game_channel,
- "bind_status": acc.bind_status,
+ **bind_payload(acc),
"elite_opened": acc.elite_opened,
"elite_expire_time": acc.elite_expire_time,
"esports_points": acc.esports_points,
diff --git a/web/backend/routers/douyu.py b/web/backend/routers/douyu.py
index 6f51f2c..8bb0094 100644
--- a/web/backend/routers/douyu.py
+++ b/web/backend/routers/douyu.py
@@ -50,6 +50,7 @@ from ..schemas import (
DouyuWorkbenchAccountsUpdate,
DouyuXpdGoodsOut,
)
+from ..services.account_binding import bind_payload
from ..services.audit_service import record_audit
from ..services.business_events import record_recharge
from ..services.douyu_runner import DouyuBatchRunner, douyu_batch_registry
@@ -138,7 +139,6 @@ async def supplier_recharge_callback(request: Request, db: Session = Depends(get
task.message = "供应商直充成功(异步通知)"
task.finished_at = datetime.now(UTC)
if task.account:
- task.account.bind_status = "gold_recharged"
task.account.updated_at = datetime.now(UTC)
elif status in {3, 4}:
reason = str(
@@ -338,14 +338,13 @@ def _account_out(account: Account) -> DouyuTaskAccountOut:
game_channel=account.game_channel or "",
gold_balance=account.gold_balance,
exchange_balance=account.exchange_balance,
- bind_status=account.bind_status or "",
+ **bind_payload(account),
change_role_wait_time=account.change_role_wait_time,
elite_opened=account.elite_opened,
elite_expire_time=account.elite_expire_time,
esports_points=account.esports_points,
esports_game_name=account.esports_game_name or "",
esports_game_channel=account.esports_game_channel or "",
- esports_bind_status=account.esports_bind_status or "",
esports_change_role_wait_time=account.esports_change_role_wait_time,
esports_can_change_time=account.esports_can_change_time,
xpd_game_name=account.xpd_game_name or "",
@@ -355,7 +354,6 @@ def _account_out(account: Account) -> DouyuTaskAccountOut:
xpd_area_id=account.xpd_area_id,
xpd_balance=account.xpd_balance,
xpd_fragments=account.xpd_fragments,
- xpd_bind_status=account.xpd_bind_status or "",
assigned_to=account.assigned_to,
assigned_username=account.assigned_user.username
if account.assigned_user
diff --git a/web/backend/schemas.py b/web/backend/schemas.py
index 8ce13ab..475b300 100644
--- a/web/backend/schemas.py
+++ b/web/backend/schemas.py
@@ -838,14 +838,19 @@ class DouyuTaskAccountOut(BaseModel):
game_channel: str = ""
gold_balance: int | None = None
exchange_balance: int | None = None
- bind_status: str = ""
+ # 绑定状态:三列各管一个作用域,取值 unknown/unbound/pending/bound
+ # (见 services/account_binding)。**`bind_status` 已废弃**,不再返回 ——
+ # 它是「最后发生的动作」式复用列,前端拿它判断绑定必然出错。
+ role_bind_status: str = "unknown"
+ role_bind_label: str = ""
change_role_wait_time: int | None = None
elite_opened: bool | None = None
elite_expire_time: int | None = None
esports_points: int | None = None
esports_game_name: str = ""
esports_game_channel: str = ""
- esports_bind_status: str = ""
+ esports_bind_status: str = "unknown"
+ esports_bind_label: str = ""
esports_change_role_wait_time: int | None = None
esports_can_change_time: int | None = None
xpd_game_name: str = ""
@@ -855,7 +860,8 @@ class DouyuTaskAccountOut(BaseModel):
xpd_area_id: int | None = None
xpd_balance: int | None = None
xpd_fragments: int | None = None
- xpd_bind_status: str = ""
+ xpd_bind_status: str = "unknown"
+ xpd_bind_label: str = ""
assigned_to: int | None = None
assigned_username: str | None = None
diff --git a/web/backend/services/account_binding.py b/web/backend/services/account_binding.py
new file mode 100644
index 0000000..ff3805a
--- /dev/null
+++ b/web/backend/services/account_binding.py
@@ -0,0 +1,153 @@
+"""角色绑定状态:把三个「垃圾场」列收敛成明确枚举。
+
+## 背景:`bind_status` 崩掉的方式
+
+历史上账号的绑定状态存在**一个自由文本列** `accounts.bind_status` 里,写入方遍布
+6 个文件、29 处,取值 20 多种,而且**语义混杂**:
+
+- 真正的状态:`bind_confirmed` / `game_not_bound`
+- 动作痕迹:`bind_qr_generated` / `goods_locked` / `elite_opened`
+- 纯查询痕迹:`points_queried` / `change_time_queried` / `csrf_token_ready`
+
+因为它是**覆盖式**的,`bind_confirmed` 会被之后任意一次查询(例如
+`limited_goods_queried`)盖掉 —— 于是「这个账号到底绑没绑」查不出来。
+`esports_bind_status` / `xpd_bind_status` 是同一个病。
+
+## 收敛规则(设计文档 §3.6)
+
+1. **状态列只放「现在是什么」**:`unknown` / `unbound` / `pending` / `bound`;
+2. **动作与查询痕迹一律不再写状态列** —— 动作已经进 `account_events`
+ (S5 的直发事件),查询痕迹本来就不该是状态;
+3. 三列各管一个作用域,**一列一义**:
+
+ | 列 | 作用域 |
+ | --- | --- |
+ | `accounts.role_bind_status` | 精英宝典角色绑定 |
+ | `accounts.esports_bind_status` | 电竞手册角色绑定 |
+ | `accounts.xpd_bind_status` | 和平小店角色绑定 |
+
+> ⚠️ `pending` 的含义是「角色已就绪 / 二维码已出,但**还没确认生效**」。
+> 斗鱼侧的疑点宁可为 `unknown`,也不要冒充 `bound`。
+"""
+
+from __future__ import annotations
+
+from ..models import Account
+
+# ── 作用域 ──────────────────────────────────────────────
+SCOPE_ELITE = "elite"
+SCOPE_ESPORTS = "esports"
+SCOPE_PEACE = "peace"
+ALL_SCOPES = (SCOPE_ELITE, SCOPE_ESPORTS, SCOPE_PEACE)
+
+# ── 取值域(**只有这四个**)────────────────────────────
+BIND_UNKNOWN = "unknown" # 还没查过 / 旧数据不可信
+BIND_UNBOUND = "unbound" # 明确没绑定
+BIND_PENDING = "pending" # 角色就绪或二维码已出,尚未确认生效
+BIND_BOUND = "bound" # 确认生效
+
+ALL_BIND_STATES = (BIND_UNKNOWN, BIND_UNBOUND, BIND_PENDING, BIND_BOUND)
+
+BIND_LABELS = {
+ BIND_UNKNOWN: "未知",
+ BIND_UNBOUND: "未绑定",
+ BIND_PENDING: "待确认",
+ BIND_BOUND: "已绑定",
+}
+
+#: 作用域 → `accounts` 上的列名
+SCOPE_COLUMNS = {
+ SCOPE_ELITE: "role_bind_status",
+ SCOPE_ESPORTS: "esports_bind_status",
+ SCOPE_PEACE: "xpd_bind_status",
+}
+
+SCOPE_LABELS = {
+ SCOPE_ELITE: "精英宝典角色",
+ SCOPE_ESPORTS: "电竞手册角色",
+ SCOPE_PEACE: "和平小店角色",
+}
+
+# ── 旧自由文本 → 新枚举 ─────────────────────────────────
+#
+# 只映射**无歧义**的取值;其余一律 `unknown`。判定标准是「这个旧值是不是
+# 明确陈述了绑定关系」:
+# - `bind_confirmed` 是「确认绑定成功」那一刻写的,而绑定不会被之后的查询撤销
+# → 可以确信是 bound;
+# - `game_not_bound` 明确说没绑 → unbound;
+# - `bind_qr_generated` / `xpd_bind_qr_ready` 只是出了二维码 → pending;
+# - `game_queried` / `points_queried` / `goods_locked` 之类**什么都没说**
+# → unknown(设计文档 §5.5:不猜测性回填)。
+#
+# ⚠️ 这三张表**必须与迁移 `20261004_0044` 里的 CASE 逐字对应**,有一条用例
+# 真跑那段 SQL 与它们比对(分叉了不会报错,只会让回填结果与运行时不一致)。
+
+LEGACY_ROLE_BIND_MAP: dict[str, str] = {
+ "bind_confirmed": BIND_BOUND,
+ "bind_qr_generated": BIND_PENDING,
+ "game_not_bound": BIND_UNBOUND,
+}
+
+LEGACY_ESPORTS_BIND_MAP: dict[str, str] = {
+ "esports_bound": BIND_BOUND,
+ "esports_bind_ready": BIND_PENDING,
+ "esports_bind_confirming": BIND_PENDING,
+ "esports_qr_created": BIND_PENDING,
+ "esports_role_switching": BIND_PENDING,
+}
+
+LEGACY_XPD_BIND_MAP: dict[str, str] = {
+ "xpd_bound": BIND_BOUND,
+ "xpd_bind_qr_ready": BIND_PENDING,
+ "xpd_not_bound": BIND_UNBOUND,
+}
+
+LEGACY_MAPS = {
+ SCOPE_ELITE: LEGACY_ROLE_BIND_MAP,
+ SCOPE_ESPORTS: LEGACY_ESPORTS_BIND_MAP,
+ SCOPE_PEACE: LEGACY_XPD_BIND_MAP,
+}
+
+
+def map_legacy_bind_status(scope: str, legacy: str | None) -> str:
+ """旧自由文本 → 新枚举;认不出来就是 `unknown`(**不猜**)。"""
+ return LEGACY_MAPS.get(scope, {}).get((legacy or "").strip(), BIND_UNKNOWN)
+
+
+def get_bind_status(account: Account, scope: str) -> str:
+ column = SCOPE_COLUMNS.get(scope)
+ if not column:
+ raise ValueError(f"未知的绑定作用域: {scope}")
+ value = getattr(account, column, "") or ""
+ # 兼容:迁移之前的库或测试构造的对象可能还没有新列的值
+ return value if value in ALL_BIND_STATES else BIND_UNKNOWN
+
+
+def set_bind_status(account: Account, scope: str, state: str) -> bool:
+ """设置某个作用域的绑定状态,返回是否真的变化。
+
+ **这是三个绑定状态列的唯一写入口**(同 `account_lifecycle` 之于 `status`)。
+ `state` 必须是四个枚举值之一;传旧自由文本会直接报错,避免又把垃圾写回去。
+ """
+ if state not in ALL_BIND_STATES:
+ raise ValueError(
+ f"绑定状态只能是 {ALL_BIND_STATES} 之一,收到 {state!r}。"
+ "动作/查询痕迹请进 account_events,不要写状态列。"
+ )
+ column = SCOPE_COLUMNS.get(scope)
+ if not column:
+ raise ValueError(f"未知的绑定作用域: {scope}")
+ if getattr(account, column, None) == state:
+ return False
+ setattr(account, column, state)
+ return True
+
+
+def bind_payload(account: Account) -> dict[str, str]:
+ """给接口/前端用的三列快照(值 + 中文标签)。"""
+ payload: dict[str, str] = {}
+ for scope in ALL_SCOPES:
+ state = get_bind_status(account, scope)
+ payload[f"{scope}_bind_status"] = state
+ payload[f"{scope}_bind_label"] = BIND_LABELS[state]
+ return payload
diff --git a/web/backend/services/douyu_runner_bind.py b/web/backend/services/douyu_runner_bind.py
index ab6d4aa..74479f1 100644
--- a/web/backend/services/douyu_runner_bind.py
+++ b/web/backend/services/douyu_runner_bind.py
@@ -11,6 +11,14 @@ 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"
@@ -228,19 +236,26 @@ class BindMixin:
)
def _apply_bind_info_to_account(
- self, account: Account, bind_info: dict, status: str
+ 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")
)
- account.bind_status = status
+ 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, status: str
+ self, account: Account, bind_info: dict, state: str | None = None
) -> None:
"""将电竞手册角色状态写入专属字段,避免覆盖精英宝典数据。"""
role_name = str(bind_info.get("role_name") or "")
@@ -252,7 +267,8 @@ class BindMixin:
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
+ if state is not None:
+ set_bind_status(account, SCOPE_ESPORTS, state)
account.updated_at = datetime.now(UTC)
def _esports_bind_state(
@@ -503,7 +519,6 @@ class BindMixin:
)
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})"
@@ -651,7 +666,7 @@ class BindMixin:
self._apply_bind_info_to_account(
account,
cooldown if cooldown_snapshot["role_name"] else before,
- "bind_confirmed" if result["bind_confirmed"] else "game_queried",
+ BIND_BOUND if result["bind_confirmed"] else None,
)
self._mark_task(
db,
@@ -688,7 +703,6 @@ class BindMixin:
"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(
@@ -792,7 +806,7 @@ class BindMixin:
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._apply_bind_info_to_account(account, before_bound, BIND_BOUND)
self._mark_task(
db,
task,
@@ -820,9 +834,8 @@ class BindMixin:
self._push_log("warning", f"确认绑定接口失败: {confirm_msg}")
# 待绑定游戏账号侧换绑限制(未到换绑时间等)导致确认失败:保留原绑定并给出明确提示
if before_bound is not None:
- self._apply_bind_info_to_account(account, before_bound, "game_queried")
+ self._apply_bind_info_to_account(account, before_bound)
else:
- account.bind_status = "game_queried"
account.updated_at = datetime.now(UTC)
self._mark_task(
db,
@@ -878,11 +891,8 @@ class BindMixin:
if after is None:
# 已生效绑定始终未出现:确认未生效(或同步延迟超时),保留原绑定
if before_bound is not None:
- self._apply_bind_info_to_account(
- account, before_bound, "game_queried"
- )
+ self._apply_bind_info_to_account(account, before_bound)
else:
- account.bind_status = "game_queried"
account.updated_at = datetime.now(UTC)
self._mark_task(
db,
@@ -913,11 +923,8 @@ class BindMixin:
# 查询接口异常:确认接口已成功时按成功处理,但保留错误信息。
# 写库优先确认前已生效绑定,避免待确认角色被误写入。
if before_bound is not None:
- self._apply_bind_info_to_account(
- account, before_bound, "bind_confirmed"
- )
+ self._apply_bind_info_to_account(account, before_bound, BIND_BOUND)
else:
- account.bind_status = "bind_confirmed"
account.updated_at = datetime.now(UTC)
# 确认接口成功了,但回查抛异常 —— 没能亲眼看到绑定生效。
# 仍然记(确认请求确实发出且成功),但 weak=True 标出来。
@@ -953,7 +960,7 @@ class BindMixin:
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._apply_bind_info_to_account(account, after, BIND_BOUND)
# 强事实:确认接口成功 + 回查命中已生效绑定。
# ⚠️ 上面还有一条 success 是「检测到账号**本来就已经绑定**」(什么都没做),
# 那条**不发事件** —— 否则用户每点一次「确认绑定」时间线就多一条假的绑定记录。
@@ -1006,9 +1013,9 @@ class BindMixin:
self._apply_esports_bind_info_to_account(
account,
state,
- "esports_bound"
+ BIND_BOUND
if esports_bound
- else ("esports_bind_ready" if role_text else "game_not_bound"),
+ else (BIND_PENDING if role_text else BIND_UNBOUND),
)
# 二维码用于重新选择角色,不应因为已有角色或换绑冷却而隐藏。
# 冷却是否允许最终由 actBind 返回结果决定。
@@ -1051,7 +1058,6 @@ class BindMixin:
"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,
@@ -1076,7 +1082,6 @@ class BindMixin:
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,
@@ -1101,9 +1106,7 @@ class BindMixin:
else "confirm_failed",
}
if not after_state["esports_bound"]:
- self._apply_esports_bind_info_to_account(
- account, after_state, "esports_bind_ready"
- )
+ self._apply_esports_bind_info_to_account(account, after_state, BIND_PENDING)
self._mark_task(
db,
task,
@@ -1113,7 +1116,7 @@ class BindMixin:
)
return
- self._apply_esports_bind_info_to_account(account, after_state, "esports_bound")
+ self._apply_esports_bind_info_to_account(account, after_state, BIND_BOUND)
# 业务事实在这一刻成立:确认接口成功 + 回查 esports_bound 为真。
# 必须在 _mark_task 之前发 —— 它内部 commit,而 emit 只 RELEASE SAVEPOINT、
# 不提交外层事务,runner 的 finally 只 close(未提交即回滚),之后发会被静默丢弃。
@@ -1155,7 +1158,7 @@ class BindMixin:
self._apply_esports_bind_info_to_account(
account,
state,
- "esports_bound"
+ BIND_BOUND
if is_bound
else ("esports_bind_ready" if role_name else "game_not_bound"),
)
@@ -1225,9 +1228,8 @@ class BindMixin:
)
# 只有已生效绑定才写账号表;待确认角色不污染 game_name
if bind_info and is_bound:
- self._apply_bind_info_to_account(account, bind_info, "game_queried")
+ self._apply_bind_info_to_account(account, bind_info)
else:
- account.bind_status = "game_queried" if has_pending else "game_not_bound"
account.updated_at = datetime.now(UTC)
result = {
"act_alias": source_alias,
@@ -1317,7 +1319,6 @@ class BindMixin:
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)
diff --git a/web/backend/services/douyu_runner_donate.py b/web/backend/services/douyu_runner_donate.py
index d06c3d6..893c5b9 100644
--- a/web/backend/services/douyu_runner_donate.py
+++ b/web/backend/services/douyu_runner_donate.py
@@ -177,7 +177,6 @@ class DonateMixin:
if refresh_errors:
result["refresh_errors"] = refresh_errors
- account.esports_bind_status = "esports_gift_donated"
account.updated_at = datetime.now(UTC)
confirmed = bool(result.get("esports_points_changed"))
message = f"赠送{gift_name}成功: {gift_count}"
@@ -285,7 +284,6 @@ class DonateMixin:
refresh_errors.append(f"积分: {exc}")
if refresh_errors:
result["refresh_errors"] = refresh_errors
- account.bind_status = "gift_donated"
account.updated_at = datetime.now(UTC)
message = f"赠送精英令成功: {gift_count}"
if account.gold_balance is not None:
diff --git a/web/backend/services/douyu_runner_gold.py b/web/backend/services/douyu_runner_gold.py
index 29b0663..acdde11 100644
--- a/web/backend/services/douyu_runner_gold.py
+++ b/web/backend/services/douyu_runner_gold.py
@@ -270,7 +270,6 @@ class GoldMixin:
result = client.create_gold_qr(
amount=amount, pay_type=int(config["gold_pay_type"])
)
- account.bind_status = "gold_qr_created"
account.updated_at = datetime.now(UTC)
self._update_task_progress(
db, task, "running", f"鱼翅 {amount} 元支付码已生成,等待到账", result
@@ -282,7 +281,6 @@ class GoldMixin:
self._mark_task(db, task, "stopped", "任务已停止", result)
return
if recharged:
- account.bind_status = "gold_recharged"
account.updated_at = datetime.now(UTC)
# 金额用**本地变量 amount**:二维码链路的 result 里没有金额
# (create_gold_qr 只返回 pay_url/token/raw),旧收集器在这里恒写
@@ -430,7 +428,6 @@ class GoldMixin:
result,
)
return
- account.bind_status = "gold_api_order_created"
account.updated_at = datetime.now(UTC)
self._update_task_progress(
db, task, "running", "供应商直充订单已创建,等待到账", result
@@ -441,7 +438,6 @@ class GoldMixin:
self._mark_task(db, task, "stopped", "任务已停止", result)
return
if status == 2:
- account.bind_status = "gold_recharged"
account.updated_at = datetime.now(UTC)
# 幂等键与**供应商回调**(routers/douyu.py)完全一致 —— 两条路径都可能
# 观测到同一笔成功(_wait_supplier_gold_order 会采纳回调结果再 mark 一次),
@@ -471,7 +467,6 @@ class GoldMixin:
):
client = self._client(cookie)
result = self._refresh_account_gold_balance(client, account)
- account.bind_status = "gold_balance_queried"
account.updated_at = datetime.now(UTC)
self._mark_task(
db,
diff --git a/web/backend/services/douyu_runner_goods.py b/web/backend/services/douyu_runner_goods.py
index 8e5e9d8..427908e 100644
--- a/web/backend/services/douyu_runner_goods.py
+++ b/web/backend/services/douyu_runner_goods.py
@@ -65,7 +65,6 @@ class GoodsMixin:
result = client.list_goods(manual_id=config["manual_id"], rid=config["rid"])
goods = result["goods"]
self._upsert_goods(db, goods)
- account.bind_status = account.bind_status or "active"
account.updated_at = datetime.now(UTC)
self._mark_task(
db,
@@ -91,7 +90,6 @@ class GoodsMixin:
)
goods = result["goods"]
self._upsert_esports_goods(db, goods)
- account.esports_bind_status = "esports_goods_refreshed"
account.updated_at = datetime.now(UTC)
self._mark_task(
db,
@@ -131,7 +129,6 @@ class GoodsMixin:
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
.first()
)
- account.bind_status = "goods_locked"
account.updated_at = datetime.now(UTC)
expire_seconds = result.get("expire_seconds")
expire_text = f",{expire_seconds} 秒内有效" if expire_seconds else ""
@@ -181,7 +178,6 @@ class GoodsMixin:
.filter(DouyuGoodsSnapshot.commodity_id == commodity_id)
.first()
)
- account.bind_status = "goods_exchanged"
account.updated_at = datetime.now(UTC)
result = {
"commodity_id": commodity_id,
@@ -369,7 +365,6 @@ class GoodsMixin:
except DouyuActivityError as exc:
finish_failed(f"预约到货失败: {exc}")
return
- account.bind_status = "goods_subscribed"
account.updated_at = datetime.now(UTC)
self._mark_task(
db,
@@ -406,7 +401,6 @@ class GoodsMixin:
except DouyuActivityError as exc:
finish_failed(f"预兑失败: {exc}")
return
- account.bind_status = "goods_pre_exchanged"
account.updated_at = datetime.now(UTC)
self._mark_task(
db,
@@ -508,7 +502,6 @@ class GoodsMixin:
"payment": payment,
}
)
- account.bind_status = "goods_exchanged"
account.updated_at = datetime.now(UTC)
# 兑换成功后自动刷新积分,更新账号最新积分信息(失败不阻断兑换成功)
points_refresh = None
@@ -616,7 +609,6 @@ class GoodsMixin:
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
result["esports_points_refresh_error"] = str(exc)
- account.esports_bind_status = "esports_goods_exchanged"
account.updated_at = datetime.now(UTC)
name = (goods.name if goods else "") or commodity_id
message = f"兑换电竞皮肤成功: {name}"
@@ -671,7 +663,6 @@ class GoodsMixin:
if not names
else f"限兑 {len(names)} 个: {', '.join(names[:5])}"
)
- account.bind_status = "limited_goods_queried"
account.updated_at = datetime.now(UTC)
self._mark_task(
db,
@@ -687,7 +678,6 @@ class GoodsMixin:
client = self._client(cookie)
result = client.exchange_records(manual_id=str(config["manual_id"]))
records = result["records"]
- account.bind_status = "exchange_records_queried"
account.updated_at = datetime.now(UTC)
self._mark_task(
db,
@@ -702,7 +692,6 @@ class GoodsMixin:
):
client = self._client(cookie)
token = client.csrf_token()
- account.bind_status = "csrf_token_ready"
account.updated_at = datetime.now(UTC)
self._mark_task(
db,
diff --git a/web/backend/services/douyu_runner_manual.py b/web/backend/services/douyu_runner_manual.py
index 57c8305..a3bb07e 100644
--- a/web/backend/services/douyu_runner_manual.py
+++ b/web/backend/services/douyu_runner_manual.py
@@ -113,7 +113,6 @@ class ManualMixin:
manual_id=manual_id,
rid=str(config.get("rid") or ""),
)
- account.bind_status = "elite_status_queried"
account.updated_at = datetime.now(UTC)
state = "已开通" if result["elite_opened"] else "未开通"
expire_text = self._format_expire_text(result["elite_expire_time"])
@@ -321,7 +320,6 @@ class ManualMixin:
baseline["elite_expire_time"]
)
points = baseline["elite_manual_score"]
- account.bind_status = "elite_opened"
account.updated_at = datetime.now(UTC)
self._mark_task(
db,
@@ -355,7 +353,6 @@ class ManualMixin:
amount=int(config["elite_amount"]),
room_id=str(config["room_id"]),
)
- account.bind_status = "elite_qr_created"
account.updated_at = datetime.now(UTC)
self._update_task_progress(
db, task, "running", "精英宝典支付码已生成,等待开通到账", result
@@ -374,7 +371,6 @@ class ManualMixin:
self._mark_task(db, task, "stopped", "任务已停止", result)
return
if opened:
- account.bind_status = "elite_opened"
account.elite_opened = True
account.updated_at = datetime.now(UTC)
# elite_opened 是**覆盖式**布尔值:开通之后再操作也看不出「什么时候开的」。
@@ -441,7 +437,8 @@ class ManualMixin:
self._push_log("warning", f"生成电竞手册支付码前查询活动状态失败: {exc}")
if baseline_manual_type is not None and baseline_manual_type >= 1:
- account.esports_bind_status = "esports_opened"
+ # 这里已确认开通:写独立状态列(以前只把它塞进 esports_bind_status)
+ account.esports_opened = True
account.updated_at = datetime.now(UTC)
self._mark_task(
db,
@@ -459,7 +456,6 @@ class ManualMixin:
room_id=str(config["room_id"]),
)
result.update(baseline_result)
- account.esports_bind_status = "esports_qr_created"
account.updated_at = datetime.now(UTC)
self._update_task_progress(
db, task, "running", "电竞手册支付码已生成,等待开通到账", result
@@ -478,7 +474,6 @@ class ManualMixin:
self._mark_task(db, task, "stopped", "任务已停止", result)
return
if opened:
- account.esports_bind_status = "esports_opened"
account.updated_at = datetime.now(UTC)
# 与精英宝典同理:状态字段是覆盖式的,这里补上「什么时候开的」。
# 注意只在**真正开通**这一支发事件;上面「基线查到已开通」那一支是检测,
@@ -528,7 +523,6 @@ class ManualMixin:
return
client = self._client(cookie)
result = self._refresh_esports_handbook(client, account, manual_id=manual_id)
- account.esports_bind_status = "esports_points_queried"
account.updated_at = datetime.now(UTC)
points = result["esports_points"]
self._mark_task(
@@ -545,7 +539,6 @@ class ManualMixin:
client = self._client(cookie)
ctn = client.acf_ccn(refresh_subscribe=False)
result = self._refresh_account_points(client, account, cookie, ctn=ctn)
- account.bind_status = "points_queried"
account.updated_at = datetime.now(UTC)
points = result["points"]
self._mark_task(
diff --git a/web/backend/services/douyu_runner_xpd.py b/web/backend/services/douyu_runner_xpd.py
index dcce16a..1bba1d9 100644
--- a/web/backend/services/douyu_runner_xpd.py
+++ b/web/backend/services/douyu_runner_xpd.py
@@ -11,6 +11,13 @@ from sqlalchemy.orm import Session
from core.douyu import DouyuActivityClient, DouyuActivityError
from ..models import Account, DouyuTask, DouyuXpdGoodsSnapshot
+from .account_binding import (
+ BIND_BOUND,
+ BIND_PENDING,
+ BIND_UNBOUND,
+ SCOPE_PEACE,
+ set_bind_status,
+)
from .business_events import record_exchange
DOUYU_XPD_BIND_POLL_SECONDS = 300
@@ -92,7 +99,7 @@ class XpdMixin:
return
area_id = self._xpd_area_id(role, account)
self._apply_xpd_role_to_account(account, role, area_id)
- account.xpd_bind_status = "xpd_bound"
+ set_bind_status(account, SCOPE_PEACE, BIND_BOUND)
db.commit()
role_text = str(role.get("role_name") or "-")
channel = (
@@ -126,7 +133,7 @@ class XpdMixin:
self._mark_task(db, task, "failed", "请先配置小店活动代号 actAlias")
return
result = client.xpd_bind_qr(act_alias=act_alias)
- account.xpd_bind_status = "xpd_bind_qr_ready"
+ set_bind_status(account, SCOPE_PEACE, BIND_PENDING)
account.updated_at = datetime.now(UTC)
db.commit()
# 记录绑定前状态:已绑定账号生成二维码后必须等扫码换绑,不能立即成功
@@ -242,7 +249,7 @@ class XpdMixin:
result = client.xpd_bind_info(act_alias=act_alias)
role_name = str(result.get("role_name") or "")
if not result.get("bind_role") or not role_name:
- account.xpd_bind_status = "xpd_not_bound"
+ set_bind_status(account, SCOPE_PEACE, BIND_UNBOUND)
account.updated_at = datetime.now(UTC)
db.commit()
self._mark_task(
@@ -263,7 +270,7 @@ class XpdMixin:
except Exception: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
account.xpd_game_name = role_name
account.updated_at = datetime.now(UTC)
- account.xpd_bind_status = "xpd_bound"
+ set_bind_status(account, SCOPE_PEACE, BIND_BOUND)
account.updated_at = datetime.now(UTC)
db.commit()
result["xpd_pending_confirm"] = False
@@ -324,7 +331,6 @@ class XpdMixin:
goods = result["goods"]
self._upsert_xpd_goods(db, goods)
self._apply_xpd_role_to_account(account, role, area_id)
- account.xpd_bind_status = "xpd_goods_refreshed"
db.commit()
self._mark_task(
db,
@@ -363,7 +369,6 @@ class XpdMixin:
balance = result.get("balance")
self._apply_xpd_role_to_account(account, role, area_id)
account.xpd_balance = balance
- account.xpd_bind_status = "xpd_balance_queried"
db.commit()
if balance is None:
self._mark_task(db, task, "failed", "未获取到小店点券余额")
@@ -427,7 +432,6 @@ class XpdMixin:
)
fragments = result.get("fragments")
account.xpd_fragments = fragments
- account.xpd_bind_status = "xpd_fragments_queried"
account.updated_at = datetime.now(UTC)
db.commit()
if fragments is None:
@@ -459,7 +463,6 @@ class XpdMixin:
embed_query=embed["query"],
act_id=str(config["xpd_act_id"]),
)
- account.xpd_bind_status = "xpd_purchase_records_queried"
account.updated_at = datetime.now(UTC)
total = result.get("total") or len(result.get("records") or [])
self._mark_task(
@@ -561,7 +564,6 @@ class XpdMixin:
account.xpd_balance = result["new_balance"]
if pay_type == 5 and result.get("new_balance") is not None:
account.xpd_fragments = result["new_balance"]
- account.xpd_bind_status = "xpd_goods_exchanged"
account.updated_at = datetime.now(UTC)
db.commit()
diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts
index 4eeeee8..eef3bd5 100644
--- a/web/frontend/src/api/types.ts
+++ b/web/frontend/src/api/types.ts
@@ -146,11 +146,18 @@ export interface AccountOverview {
exchange_balance: number | null;
game_name: string;
game_channel: string;
- bind_status: string;
+ /** 三列各管一个作用域:unknown / unbound / pending / bound */
+ role_bind_status: BindStatus;
+ role_bind_label: string;
elite_opened: boolean | null;
elite_expire_time: number | null;
esports_points: number | null;
- esports_bind_status: string;
+ esports_bind_status: BindStatus;
+ esports_bind_label: string;
+ /** 电竞手册是否已开通(与 elite_opened 对称,S6 从垃圾场列里救出来的) */
+ esports_opened: boolean | null;
+ xpd_bind_status: BindStatus;
+ xpd_bind_label: string;
esports_game_name: string;
esports_can_change_time: number | null;
stats: AccountOverviewStats;
@@ -441,6 +448,16 @@ export interface CookieDuplicateResponse {
// ==================== Douyu Activity ====================
+/** 角色绑定状态(见后端 services/account_binding)。`bind_status` 旧列已废弃。 */
+export type BindStatus = 'unknown' | 'unbound' | 'pending' | 'bound';
+
+export const BIND_LABELS: Record = {
+ unknown: '未知',
+ unbound: '未绑定',
+ pending: '待确认',
+ bound: '已绑定',
+};
+
export interface DouyuTaskAccountItem {
id: number;
username: string;
@@ -452,14 +469,16 @@ export interface DouyuTaskAccountItem {
game_channel: string;
gold_balance: number | null;
exchange_balance: number | null;
- bind_status: string;
+ role_bind_status: BindStatus;
+ role_bind_label: string;
change_role_wait_time: number | null;
elite_opened: boolean | null;
elite_expire_time: number | null;
esports_points: number | null;
esports_game_name: string;
esports_game_channel: string;
- esports_bind_status: string;
+ esports_bind_status: BindStatus;
+ esports_bind_label: string;
esports_change_role_wait_time: number | null;
esports_can_change_time: number | null;
xpd_game_name: string;
@@ -469,7 +488,8 @@ export interface DouyuTaskAccountItem {
xpd_area_id: number | null;
xpd_balance: number | null;
xpd_fragments: number | null;
- xpd_bind_status: string;
+ xpd_bind_status: BindStatus;
+ xpd_bind_label: string;
assigned_to: number | null;
assigned_username: string | null;
}
diff --git a/web/frontend/src/pages/AccountDetailPage.tsx b/web/frontend/src/pages/AccountDetailPage.tsx
index 0b6f833..0de64ab 100644
--- a/web/frontend/src/pages/AccountDetailPage.tsx
+++ b/web/frontend/src/pages/AccountDetailPage.tsx
@@ -55,6 +55,14 @@ const STATUS_TONES: Record = {
archived: 'default',
};
+/** 绑定状态 → 展示色(与后端 account_binding 的四个枚举对应)。 */
+const BIND_TONES: Record = {
+ bound: 'green',
+ pending: 'orange',
+ unbound: 'red',
+ unknown: 'default',
+};
+
function eventTag(type: string) {
const style = EVENT_STYLE[type] ?? { label: type, color: 'default' };
return {style.label};
@@ -276,7 +284,20 @@ export default function AccountDetailPage() {
{overview?.game_name || '-'}
{overview?.game_channel ? ({overview.game_channel}) : null}
- {overview?.bind_status || '-'}
+
+ {/* 三列各管一个作用域,取值 unknown/unbound/pending/bound */}
+
+
+ 精英:{overview?.role_bind_label || '未知'}
+
+
+ 电竞:{overview?.esports_bind_label || '未知'}
+
+
+ 小店:{overview?.xpd_bind_label || '未知'}
+
+
+
{remarkEditing ? (
@@ -331,7 +352,7 @@ export default function AccountDetailPage() {
/>
diff --git a/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx b/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx
index a5fcce8..6ab3b25 100644
--- a/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx
+++ b/web/frontend/src/pages/douyu-tasks/DouyuTasksPage.tsx
@@ -72,6 +72,16 @@ function parseAccountNames(text: string): string[] {
.filter((name) => name.length > 0 && !seen.has(name) && seen.add(name));
}
+/** 绑定状态小标签:取值 unknown / unbound / pending / bound。 */
+function BindStatusTag({ status, label }: { status?: string; label?: string }) {
+ if (!status) return null;
+ const tone = status === 'bound' ? 'green'
+ : status === 'pending' ? 'orange'
+ : status === 'unbound' ? 'red'
+ : 'default';
+ return {label || status};
+}
+
export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) {
const isPeaceHandbook = handbook === 'peace';
const isEsportsHandbook = handbook === 'esports';
@@ -167,17 +177,6 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
return map;
}, [tasks]);
- const latestConfirmBindTaskByAccount = useMemo(() => {
- const map = new Map();
- for (const task of tasks) {
- if (task.task_type !== 'confirm_bind') continue;
- if (!task.result) continue;
- const previous = map.get(task.account_id);
- if (!previous || task.id > previous.id) map.set(task.account_id, task);
- }
- return map;
- }, [tasks]);
-
const latestXpdQueryTaskByAccount = useMemo(() => {
const map = new Map();
for (const task of tasks) {
@@ -198,18 +197,6 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
return map;
}, [tasks]);
- const latestConfirmBindFailureByAccount = useMemo(() => {
- const map = new Map();
- for (const task of tasks) {
- if (task.task_type !== 'confirm_bind') continue;
- if (!['failed', 'error'].includes(task.status)) continue;
- if (!(task.message || '').includes('待绑定游戏账号')) continue;
- const previous = map.get(task.account_id);
- if (!previous || task.id > previous.id) map.set(task.account_id, task);
- }
- return map;
- }, [tasks]);
-
const latestEsportsStateTaskByAccount = useMemo(() => {
const map = new Map();
for (const task of tasks) {
@@ -1159,50 +1146,50 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
title: '游戏名', dataIndex: 'game_name', width: 170,
render: (_, record) => {
if (isPeaceHandbook) {
- return record.xpd_game_name
- ? {record.xpd_game_name}
- : 未查;
+ // 和平小店:角色名与绑定状态都读**账号字段**(不再从任务日志反推)。
+ return (
+
+ {record.xpd_game_name
+ ? {record.xpd_game_name}
+ : 未查}
+
+
+ );
}
+
+ // ⚠️ 账号状态一律读**账号字段**;任务日志只用来补「扫码流程中的瞬时信息」。
+ //
+ // 以前这里是反过来做的:从当前加载的任务列表里挑每个账号最新的一条
+ // (`latestConfirmBindTaskByAccount` 等)反推绑定状态。那样有两个问题:
+ // 1. 只看得见当前这一页的任务 —— 支撑任务一旦翻出窗口,显示的状态就是错的;
+ // 2. 让任务日志变成了「账号状态库」,于是它永远不敢清理(历史包袱)。
+ // 账号上的 role_bind_status / esports_bind_status / xpd_bind_status 才是权威。
+ const scopeState = isEsportsHandbook ? record.esports_bind_status : record.role_bind_status;
+ const scopeLabel = isEsportsHandbook ? record.esports_bind_label : record.role_bind_label;
+ const roleName = (isEsportsHandbook ? record.esports_game_name : record.game_name) || '';
+ const channel = (isEsportsHandbook ? record.esports_game_channel : record.game_channel) || '';
+
+ // 「待确认的新角色」是扫码后、确认前才存在的瞬时信息,账号上没有这个字段,
+ // 只能取刚跑完的那条查询任务 —— 这不影响「是否已绑定」的判断(那个读账号)。
const queryTask = isEsportsHandbook
? latestEsportsStateTaskByAccount.get(record.id) || null
: latestQueryGameTaskByAccount.get(record.id) || null;
- const confirmTask = isEsportsHandbook ? null : latestConfirmBindTaskByAccount.get(record.id) || null;
- const confirmSucceeded = Boolean(
- confirmTask
- && confirmTask.status === 'success'
- && (
- resultFlag(confirmTask.result, 'bind_confirmed')
- || resultFlag(confirmTask.result, 'is_bound_act')
- ),
- );
- const stateTask = (
- confirmSucceeded
- && (!queryTask || confirmTask!.id > queryTask.id)
- ) ? confirmTask : queryTask;
- const confirmFailureTask = latestConfirmBindFailureByAccount.get(record.id);
- const clearPending = Boolean(
- !isEsportsHandbook
- && confirmFailureTask
- && stateTask
- && confirmFailureTask.id > stateTask.id,
- );
- const stateResult = stateTask?.result || null;
- const stateRoleConfirmed = resultFlag(stateResult, 'is_bound_act') || resultFlag(stateResult, 'bind_confirmed');
- const stateRoleName = clearPending && !stateRoleConfirmed ? '' : resultText(stateResult, 'role_name');
- // role_name = 当前已生效绑定角色;pending_role_name = 扫码后待确认的新角色
- const roleName = stateRoleName
- || (isEsportsHandbook ? record.esports_game_name : record.game_name);
- const stateChannel = clearPending && !stateRoleConfirmed
+ const queryResult = queryTask?.result || null;
+ const pendingRoleName = scopeState === 'bound' ? '' : resultText(queryResult, 'pending_role_name');
+ const pendingChannel = scopeState === 'bound'
? ''
- : [resultText(stateResult, 'area_name'), resultText(stateResult, 'plat_name')]
+ : [resultText(queryResult, 'pending_area_name'), resultText(queryResult, 'pending_plat_name')]
.filter(Boolean)
.join(' / ');
- const pendingRoleName = clearPending || confirmSucceeded ? '' : resultText(stateResult, 'pending_role_name');
- const channel = stateChannel || (isEsportsHandbook ? record.esports_game_channel : record.game_channel);
- const pendingChannel = clearPending || confirmSucceeded ? '' : [resultText(stateResult, 'pending_area_name'), resultText(stateResult, 'pending_plat_name')]
- .filter(Boolean)
- .join(' / ');
- if (!roleName && !pendingRoleName) return 未查;
+
+ if (!roleName && !pendingRoleName) {
+ return (
+
+ 未查
+
+
+ );
+ }
return (
{roleName ? (
@@ -1229,6 +1216,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
) : null}
) : null}
+
);
},