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} + ); },