feat(account): S6 bind_status 三列拆解 —— 四值枚举、唯一写入口、前端改读账号

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

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

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

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

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

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

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

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

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

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

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

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

## 踩到并修掉的东西

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

## 验证

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

未做(后续):S6 的**账号页重构**(列表关键列 / 生命周期主页)保留;
`bind_status` 旧列待 S8 drop;保留策略里「状态派生类查询」现在技术上可下探到 7 天档,
保守起见留待确认(影响的是在用页面显示,不是历史)。
This commit is contained in:
yml2213
2026-10-04 16:28:06 +08:00
parent 6e729e7a80
commit a64380fbcc
21 changed files with 899 additions and 158 deletions
+55 -10
View File
@@ -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`)<br>动作痕迹进 `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 |
+320
View File
@@ -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)
)
+5 -1
View File
@@ -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 积分时不能立刻判成功,必须等积分真的增长。"""
+13 -1
View File
@@ -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"
+3 -1
View File
@@ -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()
+1 -1
View File
@@ -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 = (
@@ -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)
+18 -2
View File
@@ -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
+2 -1
View File
@@ -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,
+2 -4
View File
@@ -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
+9 -3
View File
@@ -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
+153
View File
@@ -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
+32 -31
View File
@@ -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)
@@ -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:
@@ -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,
@@ -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,
+2 -9
View File
@@ -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(
+11 -9
View File
@@ -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()
+25 -5
View File
@@ -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<BindStatus, string> = {
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;
}
+23 -2
View File
@@ -55,6 +55,14 @@ const STATUS_TONES: Record<string, string> = {
archived: 'default',
};
/** 绑定状态 → 展示色(与后端 account_binding 的四个枚举对应)。 */
const BIND_TONES: Record<string, string> = {
bound: 'green',
pending: 'orange',
unbound: 'red',
unknown: 'default',
};
function eventTag(type: string) {
const style = EVENT_STYLE[type] ?? { label: type, color: 'default' };
return <Tag color={style.color}>{style.label}</Tag>;
@@ -276,7 +284,20 @@ export default function AccountDetailPage() {
{overview?.game_name || '-'}
{overview?.game_channel ? <Text type="secondary"> ({overview.game_channel})</Text> : null}
</Descriptions.Item>
<Descriptions.Item label="绑定状态">{overview?.bind_status || '-'}</Descriptions.Item>
<Descriptions.Item label="角色绑定">
{/* 三列各管一个作用域,取值 unknown/unbound/pending/bound */}
<Space size={6} wrap>
<Tag color={BIND_TONES[overview?.role_bind_status ?? 'unknown']}>
精英:{overview?.role_bind_label || '未知'}
</Tag>
<Tag color={BIND_TONES[overview?.esports_bind_status ?? 'unknown']}>
电竞:{overview?.esports_bind_label || '未知'}
</Tag>
<Tag color={BIND_TONES[overview?.xpd_bind_status ?? 'unknown']}>
小店:{overview?.xpd_bind_label || '未知'}
</Tag>
</Space>
</Descriptions.Item>
<Descriptions.Item label="备注">
{remarkEditing ? (
<Space.Compact style={{ width: '100%' }}>
@@ -331,7 +352,7 @@ export default function AccountDetailPage() {
/>
<Statistic
title="电竞手册"
value={overview?.esports_bind_status === 'esports_opened' ? '已开通' : (overview?.esports_bind_status || '未开通')}
value={overview?.esports_opened ? '已开通' : '未开通'}
valueStyle={{ fontSize: 20 }}
/>
</div>
@@ -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 <Tag color={tone} style={{ marginInlineEnd: 0 }}>{label || status}</Tag>;
}
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<number, DouyuTaskItem>();
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<number, DouyuTaskItem>();
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<number, DouyuTaskItem>();
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<number, DouyuTaskItem>();
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
? <Text ellipsis title={record.xpd_game_name}>{record.xpd_game_name}</Text>
: <Text type="secondary">未查</Text>;
// 和平小店:角色名与绑定状态都读**账号字段**(不再从任务日志反推)。
return (
<Space direction="vertical" size={2} style={{ lineHeight: 1.35 }}>
{record.xpd_game_name
? <Text ellipsis title={record.xpd_game_name}>{record.xpd_game_name}</Text>
: <Text type="secondary">未查</Text>}
<BindStatusTag status={record.xpd_bind_status} label={record.xpd_bind_label} />
</Space>
);
}
// ⚠️ 账号状态一律读**账号字段**;任务日志只用来补「扫码流程中的瞬时信息」。
//
// 以前这里是反过来做的:从当前加载的任务列表里挑每个账号最新的一条
// (`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 <Text type="secondary">未查</Text>;
if (!roleName && !pendingRoleName) {
return (
<Space direction="vertical" size={2}>
<Text type="secondary">未查</Text>
<BindStatusTag status={scopeState} label={scopeLabel} />
</Space>
);
}
return (
<Space direction="vertical" size={2} style={{ lineHeight: 1.35 }}>
{roleName ? (
@@ -1229,6 +1216,7 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
) : null}
</Space>
) : null}
<BindStatusTag status={scopeState} label={scopeLabel} />
</Space>
);
},