feat(account): S3 生命周期状态收口 —— 6 状态状态机、唯一推导入口、停用/归档闭环

`status` 列从「空壳」变成可信状态:S1 建了列、S2 让账号独立,本提交让状态真正
反映账号处境,并把「哪些账号要人处理」变成可筛选的事实。

## 状态机(7 → 6 个状态)

`imported` / `active` / `invalid` / `manual` / `disabled` / `archived`

**砍掉 `relogin`**:它完全等价于 `invalid + relogin_next_at is not None`,
独立成状态就多出一个必须与 `relogin_next_at` 同步的字段 —— 正是要消灭的「双份真相」。

## 唯一推导入口(防「双份真相」)

新增 `services/account_lifecycle.py`:
- `derive_status(account)` —— **纯函数**,按 cookie / ck_check_status / relogin_*
  推导状态;人工态(disabled/archived)优先级最高,不被覆盖;
- `sync_status(db, account)` —— 推导 + 变了才写,**幂等**(没变则不写库、不发事件,
  否则巡检每轮都往时间线刷一条);
- `set_manual_state(db, account, state)` —— 停用/归档/`auto`(恢复自动推导);
- `on_login_success(db, account)` —— **「登录成功对账号意味着什么」只定义一次**:
  作废本轮重登退避痕迹 + 重算状态。于是**「待人工」的账号被人工登录救回来时状态
  会自动恢复**,而不是卡在 manual。
- `MANUAL_KINDS` 从 `jobs/ck_relogin.py` 挪到这里(service 不该反向依赖 job)。

**9 处底层判据写入点全部接线**:登录成功、CK 清除(单个/批量)、ck_health 巡检、
人工 CK 检测、工作台执行前检测、重登复检、重登结算(成功/失败退避)。

## 消费端:补上一个真缺口 + 删掉两处临时派生

- ⚠️ **缺口**:`ck_health` / `ck_relogin` / `count_pending_relogin` 此前只按底层判据
  选行,**没排除 disabled/archived** —— S2 提交里已标注,状态一上线就会变成
  「停用的账号照样被自动重登」。现已全部排除。
- `automation.manual_queue` 原本用 4 个条件自己拼「需要人工」,现在一句
  `status == 'manual'`。同一判据从「3 处各写一遍」收敛成「1 处推导」。

## 接口与前端

- `PUT /api/accounts/{id}/lifecycle`(disabled/archived/auto + 原因,写审计)
- `PUT /api/accounts/{id}/automation`(**接上死开关**:`auto_managed` 此前没有任何
  接口/前端能改,永远是默认 True)
- 新增权限点 `account:manage`(运营默认拥有)
- 列表支持 `?status=` 筛选;**归档默认不出现**(要看需显式筛选)
- `AccountOut` / 概览接口补 status / status_label / status_reason / auto_managed
- 前端:账号列表加「状态」列与状态筛选;详情页状态卡 + 停用/归档/恢复/自动化开关

## 两个自查出来的问题(都已修)

1. **幂等键反而丢事件**:状态事件一开始用 `status:{id}:{target}:{秒级时间戳}` 作
   `source_ref`,结果同一账号在同一秒内两次切到同一状态就撞唯一键,`emit` 静默吞掉
   —— **状态改了、时间线却少一条**,正是本模块要消灭的「悄悄不一致」。
   改为不传 `source_ref`:`sync_status` 本身已幂等,不存在重复写入路径。已加用例。
2. **迁移与运行时规则分叉**:迁移 0042 的状态回填 CASE 原本会写出已被砍掉的
   `relogin`,且判据与 `derive_status` 不一致(次数用尽的会回填成 invalid 而非 manual)。
   已改成逐字对应,并加一条用例**真跑那段 SQL 与纯函数逐条比对**(已验证:
   临时改回旧 CASE 该用例立刻红)。

## 验证

- `TZ=Asia/Shanghai`:**404 passed / 6 skipped**(S2 后 373 → 新增 31 条)
- 迁移链 3 passed;ruff check/format 全绿;前端 `tsc -b && vite build` 通过
- 真实开发库核对:落库状态分布 active 4 / imported 8,**与 derive_status 逐账号比对无漂移**
- 新增用例覆盖:derive 真值表(每状态入口 + 人工态优先)、sync 幂等、
  人工态切换、**全生命周期走查**(导入→登录→失效→重登→转人工→人工救回→清 CK→停用)、
  自动化排除 disabled/archived、列表状态筛选、接口

未做(后续阶段):S4 事件发射点补齐、S5 业务事实直发事件、S6 账号页重构。
This commit is contained in:
yml2213
2026-10-04 14:48:22 +08:00
parent 94eace50c1
commit 1a8710834a
19 changed files with 1389 additions and 76 deletions
+51 -28
View File
@@ -159,6 +159,9 @@
### 3.4 生命周期状态机
> **S3 已落地(2026-10-04)**,实现见 `web/backend/services/account_lifecycle.py`。
> 落地时把原来的 7 个状态**收敛成 6 个**:砍掉了 `relogin`(理由见下)。
```
导入
│
@@ -166,42 +169,63 @@
┌─────────┐ 登录成功 ┌────────┐ 巡检判定失效 ┌─────────┐
│imported │────────────▶│ active │───────────────▶│ invalid │
└─────────┘ └────────┘ └────┬────┘
│ ▲ ▲ │ 自动重登(可退避)
│ 人工补 CK / 手动登录 │ │ 重登成功 ▼
└────────────────────┘ │ ┌──────────┐
│ │relogin │
│ └────┬─────┘
│ │ 失败转人工 / 连续失败达上限
▲ ▲ ▲ │ 自动重登(可退避)
│ 清空 CK │ │ 重登成功/人工登录救回 │
└────────────────────┘ │ ▼
│ ┌─────────────────┐
│ │ 极验/凭证问题 │
│ │ 或重试次数用尽 │
│ └────────┬────────┘
│ ▼
│ ┌─────────┐
└──────────────────│ manual │
人工处理成功 └────┬────┘
│ 人工停用
▼
┌──────────┐ 归档 ┌──────────┐
│ disabled │───────▶│ archived │
└──────────┘ └──────────┘
│ 人工停用 / 归档
▼
┌──────────┐ ┌──────────┐
│ disabled │◀──────▶│ archived │
└──────────┘ └──────────┘
两者可互换,也可 restore 回自动态
```
| 状态 | 含义 | 能否跑业务 | 自动化是否覆盖 |
| 状态 | 判据(`derive_status`) | 能否跑业务 | 自动化 |
| --- | --- | --- | --- |
| `imported` | 已导入,从未拿到 CK | ✗ | 不巡检(没 CK 可检) |
| `active` | 有 CK,且未被判失效 | ✅ | 参与巡检 |
| `invalid` | CK 已判失效,等待自动重登 | ✗ | 参与重登 |
| `relogin` | 正在退避重试中 | ✗ | 参与重登 |
| `manual` | 重登失败已转人工(极验/凭证) | ✗ | **不参与**(避免刷屏) |
| `disabled` | 人工停用 | ✗ | 不参与 |
| `archived` | 归档,默认列表不显示 | ✗ | 不参与 |
| `imported` | 没有 CK | ✗ | 不巡检(没 CK 可检) |
| `active` | 有 CK,且 `ck_check_status != 'invalid'` | ✅ | 参与巡检 |
| `invalid` | 有 CK 但判失效,**还会自动救** | ✗ | 参与重登 |
| `manual` | 判失效且**已放弃自动救**(极验/凭证,或试过却没排下次) | ✗ | **不参与**(避免刷屏) |
| `disabled` | 人工停用(最高优先级,不被推导覆盖) | ✗ | 不参与 |
| `archived` | 人工归档,默认列表不显示(同上) | ✗ | 不参与 |
**为什么砍掉 `relogin`**:它完全等价于 `invalid + relogin_next_at is not None`。
独立成状态就多出一个**必须和 `relogin_next_at` 同步**的字段 —— 正是下面要消灭的
「双份真相」。想筛「正在退避重试的」直接查那个字段即可。
**状态与判据的分工(重要)**:
- `status` 是**人可读的当前阶段**,落库、可筛选、可统计;
- `status` **不替代**底层判据:能不能跑业务仍然要看 `cookie != '' AND ck_status != 'invalid'`;
- **写入收口**:所有状态变迁统一走一个服务(`account_lifecycle.set_status()`),
禁止各处直接赋值 `account.status = ...` —— 这是 `bind_status` 崩掉的直接教训。
- `status` 是**人可读的当前阶段**,落库、可索引筛选、可统计;
- `status` **不替代**底层判据:能不能跑业务仍然要看 `cookie != '' AND ck_check_status != 'invalid'`;
- **写入收口**:状态只由 `account_lifecycle` 写 —— `sync_status()`(按判据推导)
与 `set_manual_state()`(人工态)。**禁止各处直接赋值 `account.status = ...`**,
这是 `bind_status` 崩掉的直接教训。
> `auto_managed` 保留,但它与 `disabled` 的区别要写进 UI:`disabled` = 账号不可用;
> `auto_managed=false` = 账号可用,只是不自动跑(人工维护的特殊账号)。
> `auto_managed` 保留,与 `disabled` 的分工写进了 UI 与接口注释:
> `disabled` = 账号不可用;`auto_managed=false` = 账号可用,只是不自动跑。
> **S3 之前 `auto_managed` 是个死开关**(没有任何接口/前端能改它),本次一并接上。
### 3.4.1 防漂移:三层防线
`status` 落库意味着**两份真相**(底层判据 + 摘要),谁忘了同步页面就撒谎。防线:
1. **收口**:推导逻辑只有 `derive_status()` 一份纯函数;所有写底层判据的地方
(`cookie` / `ck_check_status` / `relogin_*`,共 **9 处**)在其后调用 `sync_status()`;
2. **幂等**:状态没变时 `sync_status()` 返回 `False`,**不写库、不发事件**
—— 否则巡检每轮都会给时间线刷一条;
3. **迁移与运行时同规则**:迁移 `0042` 的回填 SQL 与 `derive_status()` 必须一致;
有一条用例**真跑那段 SQL 与纯函数逐条比对**。分叉了不会报错,只会表现为
「迁移刚跑完是 A、下轮巡检又改成 B」。
另有 `warn_if_drift()` 供巡检顺手观测 —— 但**真正的修复是补上 `sync_status`,不是硬改**。
### 3.5 `account_events`(时间线 + 业务台账)
@@ -235,8 +259,7 @@ CREATE INDEX ix_account_events_ledger
| category | event_type | 含义 | amount |
| --- | --- | --- | --- |
| lifecycle | `imported` | 导入 | — |
| lifecycle | `status_changed` | 生命周期状态变迁(detail 里带 from/to) | — |
| lifecycle | `disabled` / `archived` | 停用 / 归档 | — |
| lifecycle | `status_changed` | **所有**状态变迁(detail 带 from/to/reason),停用与归档不再单列事件类型 | — |
| auth | `login_ok` | 登录成功(拿到新 CK) | — |
| auth | `ck_invalid` | 巡检判定 CK 失效 | — |
| auth | `relogin_ok` / `relogin_failed` | 自动重登结果 | — |
@@ -405,7 +428,7 @@ WHERE a.cookie <> src.cookie;
| --- | --- | --- | --- |
| **S1** | 迁移 0042:加列 + 回填 + 自校验;`accounts` 成为状态载体 | 账号能独立描述登录态 | — |
| **S2** | 读写一次切换:12 个文件、24 读点 / 10 写点全部改 `LoginTask.*` → `Account.*`;批次行复用逻辑重写;在途子查询 + 索引 | 登录记录与 CK 彻底解耦 | S1 |
| **S3** | 状态收口:`account_lifecycle.set_status()` 唯一写入口;`ck_health` / `ck_relogin` / 登录批次全部改走它;生命周期状态机上线 | `status` 可信 | S2 |
| **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** | `account_events` 扩展(4 列 + 索引)+ 事件发射点补齐(含 `audit_logs.account_id` 打通) | 时间线完整 | S2 |
| **S5** | 业务事实直发事件:开通 / 充值 / 积分 / 兑换 / 绑定在任务成功时写事件;`douyu_tasks` 加保留策略 | 台账可用,687MB 可控 | S4 |
| **S6** | 账号页重构:列表加状态与关键列;详情页做成「生命周期主页」(状态卡 + 台账 + 时间线 + 操作);`bind_status` 三列拆解落地 | 账号管理真正独立 | S3、S5 |
+657
View File
@@ -0,0 +1,657 @@
"""S3 回归:账号生命周期状态。
本文件守住四件事:
1. **推导正确**:`derive_status` 是真值表 —— 底层判据的每种组合对应哪个状态;
2. **人工态优先**:`disabled` / `archived` 不被自动推导冲掉;
3. **收口幂等**:状态没变时 `sync_status` 不写库、不发事件;
4. **全生命周期闭环**:导入 → 登录 → 判失效 → 重登 → 转人工 → 人工救回 → 清 CK。
外加「停用/归档的账号不参与任何自动化」这条硬约束(S3 之前是个真缺口:
作业只按底层判据选行,停用的账号照样会被自动重登)。
"""
from datetime import UTC, datetime, timedelta
import pytest
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AccountEvent, AutomationJob, User
from web.backend.services import account_lifecycle as life
from web.backend.services.automation.jobs import ck_health, ck_relogin
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)
self.user = User(username="life-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.commit()
def teardown_method(self):
self.session.close()
def make_account(self, name="acc", **kwargs) -> Account:
"""建账号:只给**底层判据**,状态一律由生产的推导函数给出。"""
account = Account(
username=name,
password="p",
email=f"{name}@e.com",
email_password="ep",
**kwargs,
)
account.status = life.derive_status(account)
self.session.add(account)
self.session.commit()
return account
def events(self, account_id: int, event_type: str) -> list[AccountEvent]:
return (
self.session.query(AccountEvent)
.filter(
AccountEvent.account_id == account_id,
AccountEvent.event_type == event_type,
)
.order_by(AccountEvent.id.asc())
.all()
)
class TestDeriveStatus:
"""真值表:底层判据 → 状态。"""
def _derive(self, **kwargs) -> str:
# 不落库,纯函数直接算
return life.derive_status(Account(**kwargs))
def test_no_cookie_is_imported(self):
assert self._derive(cookie="", ck_check_status="valid") == life.STATUS_IMPORTED
def test_no_cookie_wins_over_invalid_ck(self):
"""CK 被清空后,即使 ck_check_status 还是 invalid,也回到「未登录」。"""
assert (
self._derive(cookie="", ck_check_status="invalid", relogin_attempts=3)
== life.STATUS_IMPORTED
)
@pytest.mark.parametrize("ck", ["", "valid", "error"])
def test_cookie_without_invalid_verdict_is_active(self, ck):
assert self._derive(cookie="ck", ck_check_status=ck) == life.STATUS_ACTIVE, (
f"ck_check_status={ck!r} 不该被判成失效"
)
@pytest.mark.parametrize("kind", ["captcha", "credential"])
def test_manual_kinds_go_to_manual(self, kind):
assert (
self._derive(
cookie="ck", ck_check_status="invalid", relogin_failure_kind=kind
)
== life.STATUS_MANUAL
)
def test_exhausted_retries_go_to_manual(self):
"""试过、但没有下次排期 = 已放弃自动重登。"""
assert (
self._derive(
cookie="ck",
ck_check_status="invalid",
relogin_failure_kind="email_code",
relogin_attempts=2,
relogin_next_at=None,
)
== life.STATUS_MANUAL
)
def test_retryable_with_schedule_stays_invalid(self):
assert (
self._derive(
cookie="ck",
ck_check_status="invalid",
relogin_failure_kind="email_code",
relogin_attempts=1,
relogin_next_at=NOW + timedelta(hours=1),
)
== life.STATUS_INVALID
)
def test_never_attempted_stays_invalid(self):
"""刚判失效、还没试过 —— 仍是 invalid(会进重登队列)。"""
assert (
self._derive(cookie="ck", ck_check_status="invalid") == life.STATUS_INVALID
)
@pytest.mark.parametrize("manual_state", list(life.MANUAL_STATES))
def test_manual_states_override_everything(self, manual_state):
"""人工态优先:账号 CK 好好的,也不该被推导回 active。"""
assert (
self._derive(
cookie="ck",
ck_check_status="valid",
status=manual_state,
)
== manual_state
)
class TestSyncStatus(_Base):
def test_writes_and_emits_when_changed(self):
account = self.make_account("a", cookie="ck", ck_check_status="valid")
assert account.status == life.STATUS_ACTIVE
account.ck_check_status = "invalid"
changed = life.sync_status(self.session, account, occurred_at=NOW)
self.session.commit()
assert changed is True
assert account.status == life.STATUS_INVALID
assert account.status_changed_at == NOW
events = self.events(account.id, "status_changed")
assert len(events) == 1
assert events[0].category == life.CATEGORY_LIFECYCLE
assert events[0].detail["from"] == life.STATUS_ACTIVE
assert events[0].detail["to"] == life.STATUS_INVALID
def test_is_noop_when_unchanged(self):
"""幂等:状态没变时不写库、不发事件(否则巡检每轮都刷一条时间线)。"""
account = self.make_account("a", cookie="ck", ck_check_status="valid")
before_changed_at = account.status_changed_at
changed = life.sync_status(self.session, account, occurred_at=NOW)
self.session.commit()
assert changed is False
assert account.status == life.STATUS_ACTIVE
assert account.status_changed_at == before_changed_at
assert self.events(account.id, "status_changed") == []
def test_never_overrides_manual_state(self):
account = self.make_account("a", cookie="ck", ck_check_status="valid")
life.set_manual_state(self.session, account, life.STATUS_DISABLED)
self.session.commit()
assert account.status == life.STATUS_DISABLED
# 底层判据怎么变都不该把人工态冲掉
account.ck_check_status = "invalid"
changed = life.sync_status(self.session, account)
self.session.commit()
assert changed is False
assert account.status == life.STATUS_DISABLED
class TestManualState(_Base):
def test_disable_sets_flag_and_event(self):
account = self.make_account("a", cookie="ck", ck_check_status="valid")
changed = life.set_manual_state(
self.session,
account,
life.STATUS_DISABLED,
reason="客户退单",
actor_id=self.user.id,
occurred_at=NOW,
)
self.session.commit()
assert changed is True
assert account.status == life.STATUS_DISABLED
assert account.disabled_at == NOW
assert account.status_reason == "客户退单"
event = self.events(account.id, "status_changed")[0]
assert event.actor_type == "user"
assert event.actor_id == self.user.id
assert event.detail["reason"] == "客户退单"
def test_archive_sets_archived_at(self):
account = self.make_account("a", cookie="ck", ck_check_status="valid")
life.set_manual_state(
self.session, account, life.STATUS_ARCHIVED, occurred_at=NOW
)
self.session.commit()
assert account.status == life.STATUS_ARCHIVED
assert account.archived_at == NOW
def test_auto_restores_derived_state(self):
"""恢复 = 摘掉人工态后立刻按判据重算(账号还有效 → active)。"""
account = self.make_account("a", cookie="ck", ck_check_status="valid")
life.set_manual_state(self.session, account, life.STATUS_DISABLED)
self.session.commit()
changed = life.set_manual_state(self.session, account, "auto", occurred_at=NOW)
self.session.commit()
assert changed is True
assert account.status == life.STATUS_ACTIVE
assert account.disabled_at is None
def test_rejects_unknown_state(self):
account = self.make_account("a")
with pytest.raises(ValueError):
life.set_manual_state(self.session, account, "bogus")
class TestFullLifecycle(_Base):
"""一条走查串起所有阶段 —— 这是 S3 的验收核心。"""
def test_walkthrough(self):
# ① 导入:没有 CK
account = self.make_account("walk")
assert account.status == life.STATUS_IMPORTED
# ② 登录成功 → 正常
account.cookie = "acf_uid=1"
account.ck_check_status = ""
life.on_login_success(self.session, account, occurred_at=NOW)
self.session.commit()
assert account.status == life.STATUS_ACTIVE
# ③ 巡检判失效 → CK 失效(会进自动重登)
account.ck_check_status = "invalid"
life.sync_status(self.session, account, occurred_at=NOW)
self.session.commit()
assert account.status == life.STATUS_INVALID
# ④ 重登成功 → 回到正常
account.cookie = "acf_uid=2"
account.ck_check_status = "valid"
life.on_login_success(self.session, account, occurred_at=NOW)
self.session.commit()
assert account.status == life.STATUS_ACTIVE
# ⑤ 又失效,且是极验 → 待人工
account.ck_check_status = "invalid"
account.relogin_failure_kind = "captcha"
account.relogin_attempts = 1
life.sync_status(self.session, account, occurred_at=NOW)
self.session.commit()
assert account.status == life.STATUS_MANUAL
# ⑥ 人工手动登录救回来 → 状态自动恢复(关键:manual 不该卡死)
account.cookie = "acf_uid=3"
account.ck_check_status = "valid"
life.on_login_success(self.session, account, occurred_at=NOW)
self.session.commit()
assert account.status == life.STATUS_ACTIVE
assert account.relogin_failure_kind == ""
# ⑦ 清掉 CK → 回到「未登录」
account.cookie = ""
life.sync_status(self.session, account, occurred_at=NOW)
self.session.commit()
assert account.status == life.STATUS_IMPORTED
# ⑧ 人工停用 → 之后无论判据怎么变都保持停用
life.set_manual_state(self.session, account, life.STATUS_DISABLED)
self.session.commit()
account.cookie = "acf_uid=4"
account.ck_check_status = "valid"
life.sync_status(self.session, account)
self.session.commit()
assert account.status == life.STATUS_DISABLED
# 状态变迁都进了时间线:imported→active→invalid→active→manual→active→imported→disabled
events = self.events(account.id, "status_changed")
assert [e.detail["to"] for e in events] == [
life.STATUS_ACTIVE,
life.STATUS_INVALID,
life.STATUS_ACTIVE,
life.STATUS_MANUAL,
life.STATUS_ACTIVE,
life.STATUS_IMPORTED,
life.STATUS_DISABLED,
]
class TestAutomationSkipsManualStates(_Base):
"""停用/归档的账号**不参与任何自动化**(S3 之前的真缺口)。"""
def _job(self) -> AutomationJob:
return AutomationJob(
key=ck_health.JOB_KEY, interval_seconds=6 * 3600, batch_size=200
)
def test_ck_health_skips_disabled_and_archived(self):
normal = self.make_account("ok", cookie="ck", ck_check_status="valid")
disabled = self.make_account("off", cookie="ck", ck_check_status="valid")
archived = self.make_account("arch", cookie="ck", ck_check_status="valid")
for acc, state in (
(disabled, life.STATUS_DISABLED),
(archived, life.STATUS_ARCHIVED),
):
life.set_manual_state(self.session, acc, state)
self.session.commit()
selected = {
acc.id
for acc in ck_health.build_select_query(
self.session, self._job(), NOW
).all()
}
assert selected == {normal.id}
def test_ck_relogin_skips_disabled_and_archived(self):
def broken(name):
return self.make_account(
name,
cookie="ck",
ck_check_status="invalid",
relogin_failure_kind="email_code",
relogin_attempts=0,
)
eligible = broken("eligible")
disabled = broken("disabled")
archived = broken("archived")
life.set_manual_state(self.session, disabled, life.STATUS_DISABLED)
life.set_manual_state(self.session, archived, life.STATUS_ARCHIVED)
self.session.commit()
selected = {
acc.id
for acc in ck_relogin.build_select_query(
self.session, self._job(), NOW, max_attempts=2
).all()
}
assert selected == {eligible.id}
def test_pending_backlog_excludes_manual_states(self):
def broken(name):
return self.make_account(
name,
cookie="ck",
ck_check_status="invalid",
relogin_failure_kind="email_code",
relogin_attempts=0,
)
broken("eligible")
disabled = broken("disabled")
life.set_manual_state(self.session, disabled, life.STATUS_DISABLED)
self.session.commit()
assert ck_relogin.count_pending_relogin(self.session, max_attempts=2) == 1
def test_ck_health_run_advances_lifecycle_status(self):
"""接线点:巡检写 ck_check_status 之后,状态必须跟着变。"""
account = self.make_account("a", cookie="ck", ck_check_status="valid")
assert account.status == life.STATUS_ACTIVE
original = ck_health.check_douyu_cookie
ck_health.check_douyu_cookie = lambda _cookie: {
"valid": False,
"message": "鱼丸接口: 账户登陆异常,请重新登陆",
"fish_ball": None,
"nickname": None,
"level": None,
"checked_at": NOW,
}
try:
ck_health.run_ck_health(self.session, self._job())
finally:
ck_health.check_douyu_cookie = original
self.session.expire_all()
fresh = self.session.query(Account).filter(Account.id == account.id).one()
assert fresh.ck_check_status == "invalid"
assert fresh.status == life.STATUS_INVALID
class TestMigrationBackfillMatchesDerive(_Base):
"""迁移 0042 的状态回填 SQL 必须与 `derive_status()` **同规则**。
这是最容易悄悄分叉的地方:一边是 Python 纯函数,一边是迁移里的 SQL CASE。
分叉了不会报错,只会表现为「迁移刚跑完是 A,下一轮巡检又改成 B」。
这里把迁移里那段 UPDATE 真跑一遍,与纯函数逐条比对。
"""
def _load_migration(self):
import importlib.util
from pathlib import Path
path = (
Path(__file__).resolve().parents[1]
/ "web/backend/migrations/versions/20261003_0042_account_lifecycle.py"
)
spec = importlib.util.spec_from_file_location(
"mig_0042_account_lifecycle", path
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _reset_status_to_imported(self, names: list[str]) -> None:
"""把落库状态重置成「迁移回填前」的样子,好让回填的 WHERE 命中。"""
self.session.query(Account).filter(Account.username.in_(names)).update(
{"status": life.STATUS_IMPORTED}, synchronize_session=False
)
self.session.commit()
def test_sql_backfill_agrees_with_derive_status(self):
cases = [
# 覆盖每个状态的入口
("no_cookie", {"cookie": ""}),
("no_cookie_invalid", {"cookie": "", "ck_check_status": "invalid"}),
("fresh_cookie", {"cookie": "ck"}),
("valid", {"cookie": "ck", "ck_check_status": "valid"}),
("error_verdict", {"cookie": "ck", "ck_check_status": "error"}),
("invalid_never_tried", {"cookie": "ck", "ck_check_status": "invalid"}),
(
"invalid_backoff",
{
"cookie": "ck",
"ck_check_status": "invalid",
"relogin_failure_kind": "email_code",
"relogin_attempts": 1,
"relogin_next_at": NOW + timedelta(hours=1),
},
),
(
"captcha",
{
"cookie": "ck",
"ck_check_status": "invalid",
"relogin_failure_kind": "captcha",
"relogin_attempts": 1,
},
),
(
"credential",
{
"cookie": "ck",
"ck_check_status": "invalid",
"relogin_failure_kind": "credential",
"relogin_attempts": 1,
},
),
(
"exhausted",
{
"cookie": "ck",
"ck_check_status": "invalid",
"relogin_failure_kind": "email_code",
"relogin_attempts": 2,
"relogin_next_at": None,
},
),
]
expected: dict[str, str] = {}
for name, kwargs in cases:
expected[name] = life.derive_status(self.make_account(name, **kwargs))
names = [name for name, _ in cases]
self._reset_status_to_imported(names)
migration = self._load_migration()
with self.engine.begin() as conn:
migration._backfill_lifecycle_status(conn)
self.session.expire_all()
for name in names:
actual = (
self.session.query(Account)
.filter(Account.username == name)
.one()
.status
)
assert actual == expected[name], (
f"{name}: 迁移回填 {actual} 与 derive_status {expected[name]} 不一致"
)
def test_backfill_never_writes_removed_relogin_state(self):
"""`relogin` 状态已在 S3 砍掉,迁移不该再写出它。"""
self.make_account(
"backoff",
cookie="ck",
ck_check_status="invalid",
relogin_failure_kind="email_code",
relogin_attempts=1,
relogin_next_at=NOW + timedelta(hours=1),
)
self._reset_status_to_imported(["backoff"])
migration = self._load_migration()
with self.engine.begin() as conn:
migration._backfill_lifecycle_status(conn)
self.session.expire_all()
status = (
self.session.query(Account)
.filter(Account.username == "backoff")
.one()
.status
)
assert status in life.ALL_STATES
assert status == life.STATUS_INVALID
class TestListStatusFilter(_Base):
"""列表按状态筛选;归档默认不出现。"""
def _names(self, **kwargs) -> list[str]:
from web.backend.routers.accounts import list_accounts
result = list_accounts(
assigned_only=False,
tag="",
has_cookie=False,
search="",
page=1,
page_size=50,
include_sensitive=False,
status=kwargs.get("status", ""),
db=self.session,
current=self.user,
)
return [item.username for item in result["items"]]
def _seed(self):
self.make_account("act", cookie="ck", ck_check_status="valid")
self.make_account("imp")
off = self.make_account("off", cookie="ck", ck_check_status="valid")
arch = self.make_account("arch", cookie="ck", ck_check_status="valid")
life.set_manual_state(self.session, off, life.STATUS_DISABLED)
life.set_manual_state(self.session, arch, life.STATUS_ARCHIVED)
self.session.commit()
def test_default_hides_archived(self):
self._seed()
names = self._names()
assert "arch" not in names
assert {"act", "imp", "off"} <= set(names)
def test_filter_by_status(self):
self._seed()
assert self._names(status=life.STATUS_MANUAL) == []
assert self._names(status=life.STATUS_ARCHIVED) == ["arch"]
# disabled 是独立状态,不会出现在 active 里
assert set(self._names(status=life.STATUS_ACTIVE)) == {"act"}
assert set(self._names(status=life.STATUS_DISABLED)) == {"off"}
def test_repeated_same_target_transitions_all_land_in_timeline(self):
"""回归:同一秒内两次切到同一状态,两条事件都要落库。
曾经的幂等键 `status:{id}:{target}:{秒级时间戳}` 会在这时撞唯一键,
`emit` 静默吞掉 → **状态改了、时间线少一条**。
"""
account = self.make_account("a", cookie="ck", ck_check_status="valid")
account.ck_check_status = "invalid"
life.sync_status(self.session, account, occurred_at=NOW)
self.session.commit()
account.ck_check_status = "valid"
life.sync_status(self.session, account, occurred_at=NOW)
self.session.commit()
account.ck_check_status = "invalid"
life.sync_status(self.session, account, occurred_at=NOW)
self.session.commit()
events = self.events(account.id, "status_changed")
assert [e.detail["to"] for e in events] == [
life.STATUS_INVALID,
life.STATUS_ACTIVE,
life.STATUS_INVALID,
]
def test_list_item_carries_status_fields(self):
self.make_account("act", cookie="ck", ck_check_status="valid")
from web.backend.routers.accounts import list_accounts
result = list_accounts(
assigned_only=False,
tag="",
has_cookie=False,
search="",
page=1,
page_size=50,
include_sensitive=False,
status="",
db=self.session,
current=self.user,
)
item = result["items"][0]
assert item.status == life.STATUS_ACTIVE
assert item.status_label == "正常"
class TestLifecycleApi(_Base):
def test_disable_then_restore(self):
from web.backend.routers.accounts import (
set_account_automation,
set_account_lifecycle,
)
from web.backend.schemas import (
AccountAutomationUpdate,
AccountLifecycleUpdate,
)
account = self.make_account("api", cookie="ck", ck_check_status="valid")
res = set_account_lifecycle(
account.id,
AccountLifecycleUpdate(status=life.STATUS_DISABLED, reason="退单"),
db=self.session,
current=self.user,
)
self.session.refresh(account)
assert res["success"] and account.status == life.STATUS_DISABLED
res = set_account_lifecycle(
account.id,
AccountLifecycleUpdate(status="auto"),
db=self.session,
current=self.user,
)
self.session.refresh(account)
assert res["success"] and account.status == life.STATUS_ACTIVE
res = set_account_automation(
account.id,
AccountAutomationUpdate(auto_managed=False),
db=self.session,
current=self.user,
)
self.session.refresh(account)
assert res["success"] and account.auto_managed is False
# 关掉自动化不等于停用:账号状态仍可用
assert account.status == life.STATUS_ACTIVE
+12 -4
View File
@@ -21,6 +21,7 @@ from datetime import UTC, datetime, timedelta
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AutomationJob, LoginTask, User
from web.backend.services.account_lifecycle import derive_status
from web.backend.services.automation import register_default_jobs, reset_registry
from web.backend.services.automation.jobs import ck_relogin
@@ -72,6 +73,9 @@ class _Base:
relogin_failure_kind=relogin_failure_kind,
relogin_last_error=relogin_last_error,
)
# 状态由生产的推导函数给出(而不是测试手写):造的是底层判据,
# 「该是什么状态」由 account_lifecycle 决定 —— 顺带验证两者一致。
account.status = derive_status(account)
self.session.add(account)
self.session.commit()
return account
@@ -344,18 +348,22 @@ class TestSettlement(_Base):
assert account.relogin_next_at is None
def test_settlement_never_touches_cookie(self):
"""结算只改账号的 ``relogin_*``,绝不碰账号持有的 CK。"""
"""结算推进状态与退避进度,但**绝不碰账号持有的 CK**。
S3 之前这里断言「cookie 与 status 都不变」;状态机上线后 status
**本来就应该**被结算推进(极验 → manual)——「不碰」的对象只剩 CK。
"""
account = self.make_account("keepck", cookie="acf_uid=1")
task = self.make_attempt(
account, attempt_status="failed", attempt_message="极验验证失败: slide"
)
before = (account.cookie, account.status)
self._settle([(account, task)])
self.session.refresh(account)
assert (account.cookie, account.status) == before
# CK 本体一字未动
assert account.cookie == "acf_uid=1"
# 顺带确认结算确实跑了:分型写上了,而 CK 仍原封不动。
# 顺带确认结算确实跑了:分型写上了、状态推进到「待人工」
assert account.relogin_failure_kind == "captcha"
assert account.status == "manual"
class TestManualQueueApi(_Base):
@@ -180,8 +180,13 @@ def _backfill_login_state(bind) -> None:
def _backfill_lifecycle_status(bind) -> None:
"""按登录态推导生命周期状态。
取值域见设计文档 §3.4。这里只用**已有的确定信息**推导,不做猜测:
有 CK 且未判失效 → active;判失效则按重登进度分流;无 CK → imported。
⚠️ **这里的 CASE 必须与 `services/account_lifecycle.derive_status()` 逐字对应。**
两边规则一旦分叉,迁移刚跑完就会有一批账号的落库状态与运行时推导不一致
(表现为「回填说是 A、下一轮巡检又改成 B」)。
状态取值域 6 个:imported / active / invalid / manual / disabled / archived。
**没有 `relogin`** —— 「退避重试中」等价于 `invalid + relogin_next_at 非空`,
是 invalid 的调度细节,不单独成状态(S3 定稿时砍掉的)。
"""
bind.execute(
sa.text(
@@ -189,13 +194,12 @@ def _backfill_lifecycle_status(bind) -> None:
UPDATE accounts
SET status = CASE
WHEN cookie IS NULL OR cookie = '' THEN 'imported'
WHEN ck_check_status = 'invalid'
AND relogin_failure_kind IN ('captcha', 'credential')
WHEN COALESCE(ck_check_status, '') <> 'invalid' THEN 'active'
WHEN COALESCE(relogin_failure_kind, '') IN ('captcha', 'credential')
THEN 'manual'
WHEN ck_check_status = 'invalid'
AND relogin_next_at IS NOT NULL THEN 'relogin'
WHEN ck_check_status = 'invalid' THEN 'invalid'
ELSE 'active'
WHEN COALESCE(relogin_attempts, 0) > 0 AND relogin_next_at IS NULL
THEN 'manual'
ELSE 'invalid'
END,
status_changed_at = COALESCE(status_changed_at, updated_at, created_at)
WHERE status = 'imported' OR status IS NULL
+2
View File
@@ -18,6 +18,7 @@ PERMISSIONS = {
"account:check": "账号检测",
"account:assign": "分配账号给客服",
"account:delete": "删除账号",
"account:manage": "停用/归档账号、开关自动化",
# 登录任务
"login:batch": "发起批量登录",
"login:view_all": "查看所有登录任务",
@@ -68,6 +69,7 @@ ROLE_PERMISSIONS = {
"account:tag",
"account:check",
"account:assign",
"account:manage",
"login:batch",
"login:view_all",
"cookie:view",
+120 -4
View File
@@ -20,9 +20,11 @@ from ..models import (
from ..permissions import user_has_permission
from ..schemas import (
AccountAssign,
AccountAutomationUpdate,
AccountBulkSelection,
AccountBulkTag,
AccountImport,
AccountLifecycleUpdate,
AccountOut,
AccountTag,
BatchAssign,
@@ -34,6 +36,11 @@ from ..services.account_events_service import (
EVENT_EXCHANGE,
EVENT_RECHARGE,
)
from ..services.account_lifecycle import (
STATUS_ARCHIVED,
STATUS_LABELS,
set_manual_state,
)
from ..services.account_service import (
cookie_account_ids_query,
parse_and_build_accounts,
@@ -53,6 +60,16 @@ def _visible_accounts_query(db: Session, current: User):
raise HTTPException(status_code=403, detail="无权查看账号")
def _query_str(value) -> str:
"""把查询参数安全地取成字符串。
存在的理由:本仓库的测试**直接调用路由函数**(不经 FastAPI),此时
`Query(...)` 的默认值不会被解析,参数拿到的仍是 `Query` 对象 ——
对它调 `.strip()` 会炸。这里统一兜住,免得每加一个筛选参数就要改一遍用例。
"""
return value.strip() if isinstance(value, str) else ""
def _filter_accounts_query(
db: Session,
query,
@@ -62,15 +79,24 @@ def _filter_accounts_query(
tag: str | None = None,
has_cookie: bool = False,
search: str = "",
status: str = "",
):
"""复用列表筛选条件,供分页列表和全量批量操作保持一致。"""
if has_cookie:
query = query.filter(Account.id.in_(cookie_account_ids_query(db)))
if assigned_only and user_has_permission(current, "account:view_all"):
query = query.filter(Account.assigned_to.isnot(None))
if tag:
query = query.filter(Account.tag == tag)
search_text = (search or "").strip()
tag_value = _query_str(tag)
if tag_value:
query = query.filter(Account.tag == tag_value)
status_value = _query_str(status)
if status_value:
query = query.filter(Account.status == status_value)
else:
# 归档账号默认不出现 —— 「归档」本身就是「收起来别烦我」的意思。
# 要看它们必须显式带 status=archived。
query = query.filter(Account.status != STATUS_ARCHIVED)
search_text = _query_str(search)
if search_text:
pattern = f"%{search_text}%"
query = query.filter(
@@ -138,6 +164,7 @@ def list_accounts(
tag: str = Query(None),
has_cookie: bool = Query(False),
search: str = Query(""),
status: str = Query("", description="按生命周期状态筛选;空=全部(不含归档)"),
page: int | None = Query(None, ge=1),
page_size: int = Query(20, ge=1, le=200),
include_sensitive: bool = Query(False),
@@ -153,6 +180,7 @@ def list_accounts(
tag=tag,
has_cookie=has_cookie,
search=search,
status=status,
)
total = None
@@ -185,6 +213,10 @@ def list_accounts(
assigned_to=acc.assigned_to,
assigned_username=acc.assigned_user.username if acc.assigned_user else None,
created_at=acc.created_at,
status=acc.status or "",
status_label=STATUS_LABELS.get(acc.status or "", acc.status or ""),
status_reason=acc.status_reason or "",
auto_managed=bool(acc.auto_managed),
)
# 只有管理员可看完整字段(密码、邮箱等)
if can_include_sensitive:
@@ -415,6 +447,85 @@ def assignments_summary(
}
@router.put("/{account_id}/lifecycle")
def set_account_lifecycle(
account_id: int,
req: AccountLifecycleUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:manage")),
):
"""人工停用 / 归档 / 恢复账号(生命周期状态机,见 `services/account_lifecycle`)。
- `disabled`:账号不可用(封号、退单、归还)—— 自动化**不再碰它**;
- `archived`:软删除,默认列表不再展示;
- `auto`:解除人工态,立刻按底层判据重新推导(例如账号又能用了)。
"""
acc = db.query(Account).filter(Account.id == account_id).first()
if not acc:
raise HTTPException(status_code=404, detail="账号不存在")
if acc.status == req.status:
return {"message": "状态未变化", "status": acc.status, "success": True}
set_manual_state(
db,
acc,
req.status,
reason=req.reason or "",
actor_id=current.id,
actor_type="user",
)
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action=f"account:lifecycle:{req.status}",
target=acc.username,
account_id=acc.id,
detail=req.reason or "",
)
)
db.commit()
return {
"message": "状态已更新",
"status": acc.status,
"success": True,
}
@router.put("/{account_id}/automation")
def set_account_automation(
account_id: int,
req: AccountAutomationUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("account:manage")),
):
"""自动化开关。
与 `status=disabled` 的区别:`auto_managed=False` 的账号**仍然可用**
(人照样能手动跑任务),只是巡检/自动重登绕过它 —— 用于敏感账号人工维护。
"""
acc = db.query(Account).filter(Account.id == account_id).first()
if not acc:
raise HTTPException(status_code=404, detail="账号不存在")
acc.auto_managed = bool(req.auto_managed)
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="account:automation",
target=acc.username,
account_id=acc.id,
detail="参与自动化" if req.auto_managed else "不参与自动化",
)
)
db.commit()
return {
"message": "自动化开关已更新",
"auto_managed": acc.auto_managed,
"success": True,
}
@router.put("/{account_id}/tag")
def set_account_tag(
account_id: int,
@@ -654,6 +765,12 @@ def account_overview(
"remark": acc.remark,
"assigned_to": acc.assigned_to,
"created_at": acc.created_at, # 导入时间
# 生命周期状态(账号现在到底能不能用、要不要人处理)
"status": acc.status or "",
"status_label": STATUS_LABELS.get(acc.status or "", acc.status or ""),
"status_reason": acc.status_reason or "",
"status_changed_at": acc.status_changed_at,
"auto_managed": bool(acc.auto_managed),
# 当前状态(覆盖式字段)
"points": acc.points,
"gold_balance": acc.gold_balance,
@@ -667,7 +784,6 @@ def account_overview(
"esports_bind_status": acc.esports_bind_status,
"esports_game_name": acc.esports_game_name,
"esports_can_change_time": acc.esports_can_change_time,
"auto_managed": acc.auto_managed,
# 统计
"stats": {
"recharge_total": int(stats.get("recharge_total") or 0),
+10 -17
View File
@@ -12,7 +12,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import and_, or_, select
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..database import get_db
@@ -200,11 +200,15 @@ def manual_queue(
自动重登只处理「还能自动救」的失败;极验滑块、凭证问题、以及重试次数用尽的
必须交给人 —— 这个接口就是那份清单(规划 §7.4.6)。
"""
from ..services.automation.jobs.ck_relogin import MANUAL_KINDS
# 解耦后这一队列的单位是**账号**(2026-10-04):重登进度与 CK 都在 accounts 上。
# 「最近尝试时间」仍来自登录记录(那是尝试的属性),用相关子查询取最后一条。
**2026-10-04 起直接读生命周期状态 `status == "manual"`**:这个判据原本在这里
用 4 个条件自己拼(分型属人工 / 有错 / 无下次排期 / 试过),生命周期状态机
上线后同一件事只在 `account_lifecycle.derive_status` 里定义一次。
"""
from ..services.account_lifecycle import MANUAL_KINDS, STATUS_MANUAL
# 队列单位是**账号**。 「最近尝试时间」仍来自登录记录(那是尝试的属性),
# 用相关子查询取最后一条。
latest_attempt_at = (
select(LoginTask.attempt_at)
.where(LoginTask.account_id == Account.id)
@@ -213,20 +217,9 @@ def manual_queue(
.scalar_subquery()
)
# 转人工 = 分型属于需人工,或者次数用尽(next_at 已清空且失败过)。
manual_filter = or_(
Account.relogin_failure_kind.in_(tuple(sorted(MANUAL_KINDS))),
and_(
Account.relogin_last_error != "",
Account.relogin_next_at.is_(None),
Account.relogin_attempts > 0,
),
)
rows = (
db.query(Account, latest_attempt_at.label("last_attempt_at"))
.filter(Account.ck_check_status == "invalid")
.filter(Account.relogin_failure_kind != "")
.filter(manual_filter)
.filter(Account.status == STATUS_MANUAL)
.order_by(latest_attempt_at.desc(), Account.id.desc())
.all()
)
+15 -2
View File
@@ -33,6 +33,7 @@ from ..models import Account, AuditLog, LoginTask, User
from ..models import ProxyConfig as ProxyConfigModel
from ..permissions import get_user_permissions, user_has_permission
from ..schemas import CookieReloginRequest
from ..services.account_lifecycle import sync_status
from ..services.cookie_check_service import check_douyu_cookie
from ..services.douyu_service import (
LOGIN_ATTEMPT_IN_FLIGHT_STATUSES,
@@ -507,6 +508,7 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d
# 持久化检测结果到**账号**,刷新/翻页不丢失。
accounts_by_id = {acc.id: acc for acc in accounts}
checked_at = datetime.now(UTC)
for item in results:
acc = accounts_by_id.get(item["id"])
if not acc:
@@ -518,13 +520,19 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d
"level": item.get("level"),
"message": item.get("message", ""),
}
acc.ck_checked_at = datetime.now(UTC)
acc.ck_checked_at = checked_at
# 人工检测同样改变生命周期状态(正常 ↔ CK 失效)
sync_status(
db, acc, occurred_at=checked_at, actor_type="user", actor_id=current.id
)
# 审计带上账号:单个检测时可直接反查(批量时只能落到 target 文本里)
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="cookie:check",
target=f"检测 {len(results)} 个账号的 CK",
account_id=accounts[0].id if len(accounts) == 1 else None,
)
)
db.commit()
@@ -784,6 +792,8 @@ def delete_cookies_batch(
acc.ck_check_status = ""
acc.ck_check_result = None
acc.ck_checked_at = None
# 没有 CK 就跑不了业务 → 状态回到「未登录」
sync_status(db, acc, occurred_at=now, actor_type="user", actor_id=current.id)
# 批量清 CK 涉及多个账号,只能落到 target 文本里;单个账号时补 account_id,
# 便于审计页/账号时间线按索引反查。
db.add(
@@ -824,11 +834,14 @@ def delete_cookie(
raise HTTPException(status_code=404, detail="账号不存在或没有 CK")
account_id = acc.id
username = acc.username
now = datetime.now(UTC)
acc.cookie = ""
acc.cookie_updated_at = datetime.now(UTC)
acc.cookie_updated_at = now
acc.ck_check_status = ""
acc.ck_check_result = None
acc.ck_checked_at = None
# 没有 CK 就跑不了业务 → 状态回到「未登录」
sync_status(db, acc, occurred_at=now, actor_type="user", actor_id=current.id)
db.add(
AuditLog(
user_id=current.id,
+25
View File
@@ -148,6 +148,22 @@ class AccountTag(BaseModel):
account_ids: list[int] | None = None
class AccountLifecycleUpdate(BaseModel):
"""人工设定生命周期状态。
`status` 取 `disabled`(停用)/ `archived`(归档)/ `auto`(恢复自动推导)。
"""
status: str = Field(..., pattern="^(disabled|archived|auto)$")
reason: str = ""
class AccountAutomationUpdate(BaseModel):
"""自动化开关:false = 账号可用,但自动化不碰它(人工维护的特殊账号)。"""
auto_managed: bool
class AccountOut(BaseModel):
id: int
username: str
@@ -160,6 +176,11 @@ class AccountOut(BaseModel):
assigned_username: str | None = None
remark: str = ""
created_at: datetime | None = None
# 生命周期状态(2026-10-04 S3):客服最关心的是「哪些要人工」
status: str = ""
status_label: str = ""
status_reason: str = ""
auto_managed: bool = True
model_config = ConfigDict(from_attributes=True)
@@ -176,6 +197,10 @@ class AccountOut(BaseModel):
"assigned_username": self.assigned_username,
"remark": self.remark,
"created_at": _isoformat(self.created_at),
"status": self.status,
"status_label": self.status_label,
"status_reason": self.status_reason,
"auto_managed": self.auto_managed,
}
@@ -46,6 +46,7 @@ EVENT_ELITE_OPENED = "elite_opened" # 精英宝典开通
EVENT_ESPORTS_OPENED = "esports_opened" # 电竞手册开通
EVENT_ASSIGNED = "assigned" # 分配客服
EVENT_TAG_CHANGED = "tag_changed" # 标签变更
EVENT_STATUS_CHANGED = "status_changed" # 生命周期状态变迁(停用/归档/失效…)
# 有符号金额的事件类型(用于页面「累计充值/累计消耗」):
# 充值为正、兑换为负,其余不计入金额统计。
+293
View File
@@ -0,0 +1,293 @@
"""账号生命周期状态:推导、收口写入与人工态切换。
## 为什么需要「唯一推导函数」
账号的底层判据有三层 —— **有没有 CK**(`cookie`)、**CK 好不好**
(`ck_check_status`)、**救不救得回来**(`relogin_*`)。`status` 是它们的
「人话摘要」,让客服一眼看出该处理谁、让列表能按状态筛选。
摘要一旦落库,就存在**两份真相**:底层判据 + 摘要。谁忘了同步,页面就撒谎 ——
`accounts.bind_status` 正是这么烂掉的(36 种取值互相覆盖,最后没人知道绑定状态是啥)。
所以这里把推导收成**一个纯函数** `derive_status()`,所有写入点只调用
`sync_status()`(推导 + 变了才写),**不允许各处自己判断该写什么状态**。
## 三层防线
1. **收口**:状态只由本模块写(`sync_status` / `set_manual_state`);
2. **穷举单测**:`derive_status` 是真值表,全生命周期走查见 `test_account_lifecycle.py`;
3. **幂等**:状态没变时 `sync_status` 返回 False,**不写库、不发事件**。
## 与 `auto_managed` 的分工
- `status=disabled`:**账号不可用**(封号、退单、归还);
- `auto_managed=False`:账号可用,但**自动化别碰**(敏感账号人工维护)。
两者都由「是否参与自动化」消费,但语义不同,所以保留两个开关。
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from loguru import logger
from sqlalchemy.orm import Session
from ..models import Account
from .account_events_service import (
CATEGORY_LIFECYCLE,
EVENT_STATUS_CHANGED,
emit,
)
# ── 状态取值域(6 个)──────────────────────────────────────
# 刻意**不做** `relogin`(退避重试中):它完全等价于
# `invalid + relogin_next_at is not None`,独立成状态就多出一个必须与
# `relogin_next_at` 同步的字段 —— 正是本模块要消灭的东西。
STATUS_IMPORTED = "imported" # 没有 CK,跑不了业务
STATUS_ACTIVE = "active" # 有 CK 且未判失效
STATUS_INVALID = "invalid" # CK 判失效,还会自动救
STATUS_MANUAL = "manual" # CK 判失效,已放弃自动救,要人处理
STATUS_DISABLED = "disabled" # 人工停用
STATUS_ARCHIVED = "archived" # 人工归档(软删除)
# 由底层判据推导出来的状态。
DERIVED_STATES: tuple[str, ...] = (
STATUS_IMPORTED,
STATUS_ACTIVE,
STATUS_INVALID,
STATUS_MANUAL,
)
# 人工设定的状态:优先级最高,`derive_status` **不会**覆盖它们。
MANUAL_STATES: tuple[str, ...] = (STATUS_DISABLED, STATUS_ARCHIVED)
ALL_STATES: tuple[str, ...] = DERIVED_STATES + MANUAL_STATES
# 参与自动化的状态:disabled / archived 一律跳过。
AUTOMATABLE_STATES: tuple[str, ...] = DERIVED_STATES
# 重登失败分型 → 需要人工(自动重试只会加重风控 / 重试多少次都一样)。
# 放在这里而不是 job 里:`derive_status` 要用它,service 不该反过来依赖 job。
MANUAL_KINDS = frozenset({"captcha", "credential"})
STATUS_LABELS = {
STATUS_IMPORTED: "未登录",
STATUS_ACTIVE: "正常",
STATUS_INVALID: "CK 失效",
STATUS_MANUAL: "待人工",
STATUS_DISABLED: "已停用",
STATUS_ARCHIVED: "已归档",
}
def derive_status(account: Account) -> str:
"""按账号当前的底层判据推导应有的生命周期状态(**纯函数**)。
人工态(disabled / archived)优先级最高:即使账号还有 CK、CK 也还健康,
只要人说了停用/归档,就一直保持 —— 自动推导不能把人的决定冲掉。
"""
if account.status in MANUAL_STATES:
return account.status # 人工态不被覆盖
if not (account.cookie or ""):
# 没有 CK 就跑不了业务:从未登录成功,或被清过 CK。
return STATUS_IMPORTED
if (account.ck_check_status or "") != "invalid":
# 有 CK,且没被判失效(含「还没检测过」)。
return STATUS_ACTIVE
kind = account.relogin_failure_kind or ""
if kind in MANUAL_KINDS:
return STATUS_MANUAL # 极验 / 凭证问题,自动重试无意义
if (account.relogin_attempts or 0) > 0 and account.relogin_next_at is None:
# 试过了、却没排下一次 —— 次数用尽,等于已放弃自动重登。
return STATUS_MANUAL
return STATUS_INVALID
def _label(status: str) -> str:
return STATUS_LABELS.get(status, status or "未知")
def _apply(
db: Session,
account: Account,
target: str,
*,
reason: str = "",
actor_type: str = "system",
actor_id: int | None = None,
occurred_at: datetime | None = None,
) -> bool:
"""真正的状态写入(唯一落点):改字段 + 记事件。状态没变则什么都不做。"""
previous = account.status
if previous == target:
return False
now = occurred_at or datetime.now(UTC)
account.status = target
account.status_reason = (reason or "")[:64]
account.status_changed_at = now
if target == STATUS_DISABLED:
account.disabled_at = now
elif target == STATUS_ARCHIVED:
account.archived_at = now
else:
account.disabled_at = None
account.archived_at = None
# 状态变迁进时间线:`status` 是覆盖式字段,覆盖之后就再也看不出
# 「什么时候从正常变成失效的」。
#
# ⚠️ **刻意不传 `source_ref`**。一开始用了
# `status:{id}:{target}:{秒级时间戳}` 作幂等键,结果是:同一个账号在同一秒内
# 两次切到同一状态(active→invalid→active→…,甚至只是测试里时间被固定)
# 就会撞唯一键,`emit` 静默吞掉异常 —— **状态改了、时间线却少一条**,
# 正是本模块要消灭的那种「悄悄不一致」。
#
# 而且这里根本不需要幂等键:`sync_status` 本身就是幂等的(状态没变直接返回
# False,压根不会走到这儿),所以不存在「同一次变迁被写两次」的路径。
emit(
db,
account_id=account.id,
event_type=EVENT_STATUS_CHANGED,
title=f"状态:{_label(previous)} → {_label(target)}",
category=CATEGORY_LIFECYCLE,
occurred_at=now,
detail={
"from": previous or "",
"to": target,
"reason": reason or "",
},
source="lifecycle",
actor_type=actor_type,
actor_id=actor_id,
)
return True
def sync_status(
db: Session,
account: Account,
*,
occurred_at: datetime | None = None,
actor_type: str = "system",
actor_id: int | None = None,
) -> bool:
"""按底层判据推导并写入状态。**返回状态是否真的变了**。
调用时机:任何改动 `cookie` / `ck_check_status` / `relogin_*` 之后。
幂等 —— 状态没变时既不写库也不发事件。
"""
target = derive_status(account)
if target == account.status:
return False
if account.id is None:
# 还没落库(新建账号)—— 直接给字段,等 flush 后自然带上去。
account.status = target
account.status_changed_at = occurred_at or datetime.now(UTC)
return True
return _apply(
db,
account,
target,
reason="",
actor_type=actor_type,
actor_id=actor_id,
occurred_at=occurred_at,
)
def set_manual_state(
db: Session,
account: Account,
state: str,
*,
reason: str = "",
actor_id: int | None = None,
actor_type: str = "user",
occurred_at: datetime | None = None,
) -> bool:
"""人工设定状态:`disabled` / `archived` / `auto`(恢复自动推导)。
传 `auto` 表示解除人工态,立刻按底层判据重新推导。
"""
if state == "auto":
account.status = STATUS_IMPORTED # 先把人工态摘掉,否则 derive 会原样返回
target = derive_status(account)
if target == STATUS_IMPORTED:
# 推导结果本来就是 imported:直接落字段,不必发「假」的变迁事件
account.status = STATUS_IMPORTED
account.status_changed_at = occurred_at or datetime.now(UTC)
account.disabled_at = None
account.archived_at = None
return True
return _apply(
db,
account,
target,
reason=reason,
actor_type=actor_type,
actor_id=actor_id,
occurred_at=occurred_at,
)
if state not in MANUAL_STATES:
raise ValueError(f"不支持的人工状态:{state}")
# 从人工态切到另一个人工态时,previous 要按真实值取,事件才准
return _apply(
db,
account,
state,
reason=reason,
actor_type=actor_type,
actor_id=actor_id,
occurred_at=occurred_at,
)
def on_login_success(
db: Session,
account: Account,
*,
occurred_at: datetime | None = None,
) -> None:
"""登录成功对账号意味着什么:**这一轮重登的退避痕迹作废,然后重算状态**。
只在这里定义一次,而不是散在 runner 与结算两处 —— 两处各写一遍就会出现
「谁先谁后」的差异。
人工手动登录也走这条,于是**「待人工」的账号被人工登录救回来时,状态会正确
回到正常**,而不是继续挂在 manual 上(`relogin_failure_kind` 清空后
`derive_status` 不再判它 manual)。
"""
account.relogin_attempts = 0
account.relogin_next_at = None
account.relogin_failure_kind = ""
account.relogin_last_error = ""
sync_status(db, account, occurred_at=occurred_at)
def status_payload(account: Account) -> dict[str, Any]:
"""给接口用的状态片段(含中文标签,前端不必再维护一份映射)。"""
return {
"status": account.status or "",
"status_label": _label(account.status or ""),
"status_reason": account.status_reason or "",
"status_changed_at": account.status_changed_at,
}
def warn_if_drift(account: Account) -> None:
"""漂移告警:落库状态与推导结果不一致时记一条 warning(不写库)。
作为「有人新增了底层判据写入点却忘了调 sync_status」的兜底观测手段,
由巡检作业顺手调用。真正的修复是补上 sync_status,不是在这里硬改。
"""
expected = derive_status(account)
if (account.status or "") != expected:
logger.warning(
f"账号状态漂移:account={account.id} 落库={account.status} 推导={expected}"
)
@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
from ....models import Account, AutomationJob
from ...account_events_service import EVENT_CK_INVALID, emit
from ...account_lifecycle import MANUAL_STATES, sync_status
from ...cookie_check_service import check_douyu_cookie
from ...douyu_service import cookie_operable_filter
@@ -64,7 +65,7 @@ def _candidates_query(db: Session, job: AutomationJob, now: datetime):
"""本轮「待检测账号」的完整候选集(**不带 LIMIT**,供计数复用)。
口径与人工点检测一致(``cookie_operable_filter``:账号有 CK 且没有在途尝试),
再加两条:距上次检测超过作业间隔、账号允许自动管理。
再加三条:距上次检测超过作业间隔、账号允许自动管理、**未被停用/归档**。
排序刻意不用 ``NULLS FIRST``:那是 PostgreSQL/SQLite 语法,**MySQL 会报
1064 语法错误**(生产踩过一次)。改用 ``ck_checked_at IS NULL DESC``,
@@ -75,6 +76,8 @@ def _candidates_query(db: Session, job: AutomationJob, now: datetime):
db.query(Account)
.filter(cookie_operable_filter())
.filter(Account.auto_managed.is_(True))
# 生命周期状态机:停用/归档的账号不参与任何自动化(见 account_lifecycle)。
.filter(Account.status.notin_(MANUAL_STATES))
.filter(
or_(
Account.ck_checked_at.is_(None),
@@ -168,6 +171,9 @@ def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]:
}
account.ck_checked_at = now
# 巡检结论直接决定生命周期状态(正常 ↔ CK 失效),推导交给收口函数。
sync_status(db, account, occurred_at=now)
# 判定失效时记一条账号事件:`ck_check_status` / `ck_checked_at` 都是**覆盖式**
# 字段,覆盖之后就再也看不出「什么时候开始失效的」。只在真的判成 invalid 时记,
# 避免每轮巡检都给同一个账号刷屏。
@@ -24,14 +24,20 @@ from ....database import SessionLocal
from ....models import Account, AutomationJob, LoginTask, User
from ....models import ProxyConfig as ProxyConfigModel
from ...account_events_service import EVENT_RELOGIN_FAILED, emit
from ...account_lifecycle import (
MANUAL_KINDS,
MANUAL_STATES,
on_login_success,
sync_status,
)
from ...douyu_service import cookie_holding_filter, cookie_operable_filter
from ...login_service import LoginBatchRunner, get_relogin_limits
JOB_KEY = "ck_relogin"
# 失败分型 → 后续动作。
# 需要人工的:极验滑块(自动重试只会加重风控)、凭证问题(重试多少次都一样)。
MANUAL_KINDS = frozenset({"captcha", "credential"})
# `MANUAL_KINDS`(极验 / 凭证 → 转人工)已挪到 `services/account_lifecycle.py`:
# 生命周期状态推导要用它,service 不该反过来依赖 job。
# 可自动重试的:取码失败/超时(多为邮件延迟)、中断(服务重启或人工停止)、
# 网络异常。email_quota 单列,因为它要退避到次日而不是按小时退避。
RETRYABLE_KINDS = frozenset({"email_code", "interrupted", "network"})
@@ -97,6 +103,8 @@ def build_select_query(
.filter(Account.ck_check_status == "invalid")
.filter(cookie_operable_filter())
.filter(Account.auto_managed.is_(True))
# 停用/归档的账号一律不碰(生命周期状态机,见 account_lifecycle)。
.filter(Account.status.notin_(MANUAL_STATES))
.filter(Account.relogin_attempts < max_attempts)
.filter(
or_(
@@ -186,6 +194,8 @@ def count_pending_relogin(db: Session, *, max_attempts: int = 2) -> int:
.filter(Account.ck_check_status == "invalid")
.filter(cookie_holding_filter())
.filter(Account.auto_managed.is_(True))
# 停用/归档不计入积压(它们不会被自动处理,显示出来只会误导)
.filter(Account.status.notin_(MANUAL_STATES))
.filter(Account.relogin_attempts < max_attempts)
.filter(
func.coalesce(Account.relogin_failure_kind, "").notin_(
@@ -336,10 +346,8 @@ def settle_results(
account, task = entry
if task.attempt_status == "success":
counters["valid"] += 1
account.relogin_attempts = 0
account.relogin_next_at = None
account.relogin_failure_kind = ""
account.relogin_last_error = ""
# 重登成功:清退避痕迹 + 重算状态(与人工登录成功同一套语义)
on_login_success(db, account, occurred_at=now)
continue
counters["invalid"] += 1
@@ -366,9 +374,13 @@ def settle_results(
source="login",
source_ref=f"relogin_failed:{task.id}:{int(now.timestamp())}",
)
# 状态跟着落到 inactive 的分支(invalid 或 manual)——推导函数负责判定
sync_status(db, account, occurred_at=now)
continue
if kind == "email_quota":
counters["email_quota"] += 1
counters["retry"] += 1
account.relogin_next_at = _next_retry_at(kind, account.relogin_attempts, now)
# 排了下一次重试 → 仍是 invalid(不是 manual)
sync_status(db, account, occurred_at=now)
return counters
+3
View File
@@ -10,6 +10,7 @@ from core.douyu import DouyuActivityError
from ..database import SessionLocal
from ..models import DouyuTask
from .account_lifecycle import sync_status
from .cookie_check_service import check_douyu_cookie
from .douyu_runner_bind import BindMixin
from .douyu_runner_core import ( # noqa: F401 (douyu_batch_registry 供 routers 重导出)
@@ -72,6 +73,8 @@ class DouyuBatchRunner(
for key in ("fish_ball", "nickname", "level", "message")
}
account.ck_checked_at = cookie_check["checked_at"]
# 执行前的这次检测同样会改变账号状态(正常 ↔ CK 失效)。
sync_status(worker_db, account, occurred_at=cookie_check["checked_at"])
worker_db.commit()
if not cookie_check["valid"]:
message = f"Cookie 已失效,请重新登录:{cookie_check['message']}"
+4
View File
@@ -22,6 +22,7 @@ from ..models import Account as AccountModel
from ..models import LoginTask
from ..models import ProxyConfig as ProxyConfigModel
from .account_events_service import EVENT_LOGIN_OK, EVENT_RELOGIN_OK, emit
from .account_lifecycle import on_login_success
from .cookie_check_service import check_douyu_cookie
from .douyu_service import LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
@@ -171,6 +172,9 @@ def _write_login_success(
account.cookie = cookie
account.cookie_updated_at = completed_at
account.cookie_source = "web"
# 登录成功同时作废上一轮重登的退避痕迹,并把状态重算回正常。
# 人工手动登录也走这条 →「待人工」的账号被人工救回来时状态会正确恢复。
on_login_success(db, account, occurred_at=completed_at)
emit(
db,
+7 -1
View File
@@ -17,7 +17,7 @@ import type {
export const accountApi = {
list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean }) =>
api.get<AccountItem[], AccountItem[]>('/accounts', { params }),
listPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean }) =>
listPaged: (params: PageParams & { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean; status?: string }) =>
api.get<PaginatedResponse<AccountItem>, PaginatedResponse<AccountItem>>('/accounts', { params }),
summary: () => api.get<BasicSummary, BasicSummary>('/accounts/summary'),
import: (text: string, tag?: string) =>
@@ -40,6 +40,12 @@ export const accountApi = {
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/accounts/batch/delete', { params: { account_ids: account_ids.join(',') } }),
batchDeleteSelection: (data: AccountBulkSelection) =>
api.post<MessageDeletedResponse, MessageDeletedResponse>('/accounts/batch-delete', data),
// 生命周期:disabled / archived / auto(恢复自动推导)
setLifecycle: (id: number, status: string, reason = '') =>
api.put<MessageResponse, MessageResponse>(`/accounts/${id}/lifecycle`, { status, reason }),
// 自动化开关:false = 账号可用但自动化不碰它
setAutomation: (id: number, auto_managed: boolean) =>
api.put<MessageResponse, MessageResponse>(`/accounts/${id}/automation`, { auto_managed }),
// 详情页:状态卡 + 统计
overview: (id: number) => api.get<AccountOverview, AccountOverview>(`/accounts/${id}/overview`),
// 时间线(倒序分页),event_type 为空表示全部
+19 -1
View File
@@ -129,6 +129,12 @@ export interface AccountOverview {
assigned_to: number | null;
/** 导入时间 */
created_at: string | null;
/** 生命周期状态(2026-10-04 S3) */
status: AccountStatus | '';
status_label: string;
status_reason: string;
status_changed_at: string | null;
auto_managed: boolean;
points: number | null;
gold_balance: number | null;
exchange_balance: number | null;
@@ -141,7 +147,6 @@ export interface AccountOverview {
esports_bind_status: string;
esports_game_name: string;
esports_can_change_time: number | null;
auto_managed: boolean;
stats: AccountOverviewStats;
/** 开通时间来自事件表(上线后才开始记录,更早的不显示) */
opened_at: { elite: string | null; esports: string | null };
@@ -204,6 +209,14 @@ export interface UserInfo {
// ==================== Account ====================
export type AccountStatus =
| 'imported'
| 'active'
| 'invalid'
| 'manual'
| 'disabled'
| 'archived';
export interface AccountItem {
id: number;
username: string;
@@ -215,6 +228,11 @@ export interface AccountItem {
assigned_username: string | null;
remark: string;
created_at: string | null;
// 生命周期状态(2026-10-04 S3)
status: AccountStatus | '';
status_label: string;
status_reason: string;
auto_managed: boolean;
}
export interface AssignmentsSummary {
+79 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Button, Card, Descriptions, Empty, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography,
Button, Card, Descriptions, Empty, Popconfirm, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import {
@@ -34,6 +34,16 @@ const EVENT_STYLE: Record<string, { label: string; color: string }> = {
tag_changed: { label: '标签', color: 'default' },
};
/** 生命周期状态 → 展示色(与账号列表页保持一致,取值域见后端 account_lifecycle)。 */
const STATUS_TONES: Record<string, string> = {
imported: 'default',
active: 'green',
invalid: 'red',
manual: 'orange',
disabled: 'default',
archived: 'default',
};
function eventTag(type: string) {
const style = EVENT_STYLE[type] ?? { label: type, color: 'default' };
return <Tag color={style.color}>{style.label}</Tag>;
@@ -144,16 +154,74 @@ export default function AccountDetailPage() {
}
const stats = overview?.stats;
const status = overview?.status || '';
/** 人工切换生命周期状态:disabled / archived / auto(恢复自动推导)。 */
const handleLifecycle = async (next: 'disabled' | 'archived' | 'auto') => {
if (!accountId) return;
try {
await accountApi.setLifecycle(accountId, next);
message.success('状态已更新');
void loadOverview();
void loadEvents();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleAutomation = async (autoManaged: boolean) => {
if (!accountId) return;
try {
await accountApi.setAutomation(accountId, autoManaged);
message.success(autoManaged ? '已恢复自动化' : '已设为不参与自动化');
void loadOverview();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
return (
<div style={{ padding: 4 }}>
<Space style={{ marginBottom: 12 }}>
<Space style={{ marginBottom: 12 }} wrap>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/accounts')}>
返回账号列表
</Button>
<Button icon={<ReloadOutlined />} onClick={() => { void loadOverview(); void loadEvents(); }}>
刷新
</Button>
{status === 'disabled' || status === 'archived' ? (
<Popconfirm
title="恢复这个账号?将按当前 CK 情况重新判定状态"
onConfirm={() => void handleLifecycle('auto')}
>
<Button>恢复</Button>
</Popconfirm>
) : (
<>
<Popconfirm
title="停用这个账号?停用后不再参与任何自动化"
onConfirm={() => void handleLifecycle('disabled')}
>
<Button danger>停用</Button>
</Popconfirm>
<Popconfirm
title="归档这个账号?默认列表将不再显示它"
onConfirm={() => void handleLifecycle('archived')}
>
<Button>归档</Button>
</Popconfirm>
</>
)}
{overview?.auto_managed === false ? (
<Button onClick={() => void handleAutomation(true)}>恢复自动化</Button>
) : (
<Popconfirm
title="设为不参与自动化?账号仍可用,只是巡检/重登不再碰它"
onConfirm={() => void handleAutomation(false)}
>
<Button>不参与自动化</Button>
</Popconfirm>
)}
</Space>
<Spin spinning={loading}>
@@ -161,6 +229,9 @@ export default function AccountDetailPage() {
title={
<Space>
<Title level={5} style={{ margin: 0 }}>{overview?.username || '—'}</Title>
<Tag color={STATUS_TONES[status] ?? 'default'}>
{overview?.status_label || '—'}
</Tag>
{overview?.tag && <Tag color="blue">{overview.tag}</Tag>}
{overview?.auto_managed === false && <Tag color="orange">不参与自动化</Tag>}
</Space>
@@ -177,6 +248,12 @@ export default function AccountDetailPage() {
</Descriptions.Item>
<Descriptions.Item label="绑定状态">{overview?.bind_status || '-'}</Descriptions.Item>
<Descriptions.Item label="备注">{overview?.remark || '-'}</Descriptions.Item>
<Descriptions.Item label="状态变更">
{formatTime(overview?.status_changed_at)}
{overview?.status_reason ? (
<Text type="secondary">({overview.status_reason})</Text>
) : null}
</Descriptions.Item>
</Descriptions>
<div style={{ display: 'flex', gap: 32, marginTop: 16, flexWrap: 'wrap' }}>
+54 -2
View File
@@ -19,6 +19,30 @@ const TAG_COLORS = [
'blue', 'green', 'cyan', 'geekblue', 'purple', 'orange', 'magenta', 'volcano',
];
/**
* 生命周期状态 → 展示色。
* 取值域见后端 `services/account_lifecycle.py`(6 个状态,不含 relogin ——
* 「退避中」是 invalid 的调度细节,不单独成状态)。
*/
const STATUS_TONES: Record<string, string> = {
imported: 'default',
active: 'green',
invalid: 'red',
manual: 'orange',
disabled: 'default',
archived: 'default',
};
/** 状态筛选项:与后端 ALL_STATES 保持一致。 */
const STATUS_OPTIONS = [
{ value: 'active', label: '正常' },
{ value: 'invalid', label: 'CK 失效' },
{ value: 'manual', label: '待人工' },
{ value: 'imported', label: '未登录' },
{ value: 'disabled', label: '已停用' },
{ value: 'archived', label: '已归档' },
];
export default function AccountsPage() {
const navigate = useNavigate();
// App.useApp() 的 modal 实例继承主题,静态 Modal.confirm 在暗色模式下是白底
@@ -32,6 +56,7 @@ export default function AccountsPage() {
const [importTag, setImportTag] = useState('');
const [importing, setImporting] = useState(false);
const [tagFilter, setTagFilter] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [searchText, setSearchText] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [selectedAllMatching, setSelectedAllMatching] = useState(false);
@@ -57,8 +82,9 @@ export default function AccountsPage() {
const loadAccounts = useCallback(async () => {
setLoading(true);
try {
const params: { tag?: string } = {};
const params: { tag?: string; status?: string } = {};
if (tagFilter) params.tag = tagFilter;
if (statusFilter) params.status = statusFilter;
const data = await accountApi.listPaged({
...params,
page: currentPage,
@@ -73,7 +99,7 @@ export default function AccountsPage() {
} finally {
setLoading(false);
}
}, [canViewFull, currentPage, pageSize, searchText, sensitiveVisible, tagFilter]);
}, [canViewFull, currentPage, pageSize, searchText, sensitiveVisible, tagFilter, statusFilter]);
const loadSummary = useCallback(async () => {
try {
@@ -263,6 +289,20 @@ export default function AccountsPage() {
</Button>
),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (status: string, record: AccountItem) => {
const tone = STATUS_TONES[status] ?? 'default';
return (
<Space size={4}>
<Tag color={tone}>{record.status_label || status || '-'}</Tag>
{record.auto_managed === false && <Tag color="orange">不自动</Tag>}
</Space>
);
},
},
{
title: '标签',
dataIndex: 'tag',
@@ -358,6 +398,18 @@ export default function AccountsPage() {
options={tags.map((t) => ({ value: t, label: t }))}
prefix={<FilterOutlined />}
/>
<Select
allowClear
size="small"
placeholder="按状态筛选"
style={{ width: 130 }}
value={statusFilter || undefined}
onChange={(val) => {
setStatusFilter(val || '');
setCurrentPage(1);
}}
options={STATUS_OPTIONS}
/>
<Input.Search
allowClear
size="small"