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 天档(待确认)。
182 lines
7.2 KiB
Python
182 lines
7.2 KiB
Python
"""角色绑定状态:把三个「垃圾场」列收敛成明确枚举。
|
||
|
||
## 背景:`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
|