Files
live-hub-py/web/backend/services/account_binding.py
T
yml2213 885a780bd3 feat(account): S6 账号页重构 —— 列表关键列 + 详情页生命周期状态卡
S6 的展示层部分(数据模型部分见上一个提交)。目标:客服打开账号页要能一眼回答
「这账号能不能用、要不要我处理、账上还有多少」。

## 账号列表:加关键列 + 三个筛选

新增四列,**全部直接来自 `accounts` 行**(不 JOIN、不聚合 —— 列表是全站最热接口):

| 列 | 内容 |
| --- | --- |
| **CK** | 有无 CK / 正常 / 已失效 / 检测异常 + 最近检测时间 |
| **绑定** | 精/电/店 三个作用域各一个小标签(✓ / … / ✗ / ?) |
| **积分 / 鱼翅** | 两行小字 |

新增筛选:**CK 健康**、**角色绑定**(三个作用域任一命中即算)。
`AccountOut` 相应补齐 `has_cookie` / `ck_check_status` / `ck_checked_at` /
`points` / `gold_balance` / 三组绑定状态与标签。

## 账号详情页:生命周期主页

顶部新增**生命周期状态卡**,把之前散落的信息聚成一眼可读的几格:

- **生命周期状态**:状态标签 + 不参与自动化标记 + 变更时间与原因
- **CK**:有无 / 健康 / 最近检测时间
- **角色绑定**:精/电/店三格
- **账上还有多少**:积分 / 鱼翅 / 兑换余额
- **归属与标注**:分配客服(下拉)+ 标签(就地编辑)—— 之前详情页只能看不能改

业务台账补上**登录成功次数**与**最近登录**(`stats` 里本来就有,只是没展示)。
时间线已有分类筛选,生命周期操作入口(停用/归档/恢复/自动化)S3 已加。

## ⚠️ 顺带修掉一个静默 bug

`bind_payload()` 的字段名按**作用域名**拼成了 `elite_bind_status` / `peace_bind_status`,
而 schema 声明的是 `role_bind_status` / `xpd_bind_status`。Pydantic 默认
`extra='ignore'`,于是多出来的键**被静默丢弃**、声明过的字段保持默认值 ——
**接口永远返回 unknown 而不报任何错**(这正是它躲过前面几轮测试的原因)。

修法:前缀改为由**列名**推导(`SCOPE_PAYLOAD_PREFIX`),并在模块导入时自检
「载荷前缀 == 列名前缀」。另加两条用例:`bind_payload` 的键必须都被
`DouyuTaskAccountOut` / `AccountOut` 声明(这是能抓到这类错的那种用例)。

顺带发现我自己之前那条 payload 用例**把错误的前缀当成了预期**,一并修正。

## 自己踩的坑

详情页加客服列表时又把 `useEffect` 写在了早期 `return` 之后 ——
和本次会话前面修 `LoginTasksPage` 的是同一个错。eslint(`react-hooks/rules-of-hooks`)
当场抓住,已上移。**`tsc` 与 `vite build` 都不会报这个**。

另外:列表关键列那处编辑我曾以为已生效(脚本打印了成功),实际没落到文件里 ——
后面是靠「读回文件确认」和用例失败才发现。已改为每次都读回核对。

## 验证

- `TZ=Asia/Shanghai`:**497 passed / 6 skipped**(S6 前半 490 → +7)
- `TZ=UTC`(相关文件)通过;ruff 全绿;前端 build 通过、lint **0 errors**
- **真实开发库端到端**:列表与工作台接口都返回了真实值
  (`has_cookie=True` / `ck_check_status=valid` / `role_bind_label=未知`),
  修 bug 前这两处返回的是被丢弃后的默认值

未做:`bind_status` 旧列待 S8 drop;保留策略里「状态派生类查询」已可下探到 7 天档(待确认)。
2026-10-04 16:45:39 +08:00

182 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""角色绑定状态:把三个「垃圾场」列收敛成明确枚举。
## 背景:`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",
}
#: 作用域 → 接口载荷里的字段前缀。
#: ⚠️ **必须与列名一致**(`role_bind_status` / `esports_bind_status` / `xpd_bind_status`)。
#: 第一版这里按作用域名拼成了 `elite_bind_status` / `peace_bind_status`,
#: 与 schema 声明的字段名对不上 —— Pydantic 会静默用默认值,
#: 表现为「接口永远返回 unknown」,且**不报错**。有一条用例比对 schema 字段名。
SCOPE_PAYLOAD_PREFIX = {
SCOPE_ELITE: "role",
SCOPE_ESPORTS: "esports",
SCOPE_PEACE: "xpd",
}
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 _assert_prefixes_match_columns() -> None:
"""自检:载荷前缀必须等于列名前缀(`role_bind_status` ↔ `role_bind_*`)。
这是被真实 bug 逼出来的 —— 前缀写错只会让接口静默返回默认值。
"""
for scope, column in SCOPE_COLUMNS.items():
expected = column.removesuffix("_bind_status")
assert SCOPE_PAYLOAD_PREFIX[scope] == expected, (
f"{scope}: 载荷前缀 {SCOPE_PAYLOAD_PREFIX[scope]!r} "
f"与列名 {column!r} 不一致"
)
_assert_prefixes_match_columns()
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:
prefix = SCOPE_PAYLOAD_PREFIX[scope]
state = get_bind_status(account, scope)
payload[f"{prefix}_bind_status"] = state
payload[f"{prefix}_bind_label"] = BIND_LABELS[state]
return payload