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:
+51
-28
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" # 生命周期状态变迁(停用/归档/失效…)
|
||||
|
||||
# 有符号金额的事件类型(用于页面「累计充值/累计消耗」):
|
||||
# 充值为正、兑换为负,其余不计入金额统计。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']}"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 为空表示全部
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user