diff --git a/docs/设计-账号全生命周期.md b/docs/设计-账号全生命周期.md index 4aff137..462f6c8 100644 --- a/docs/设计-账号全生命周期.md +++ b/docs/设计-账号全生命周期.md @@ -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 | diff --git a/tests/test_account_lifecycle.py b/tests/test_account_lifecycle.py new file mode 100644 index 0000000..d3472cb --- /dev/null +++ b/tests/test_account_lifecycle.py @@ -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 diff --git a/tests/test_automation_ck_relogin.py b/tests/test_automation_ck_relogin.py index 3a53999..6f3fbe2 100644 --- a/tests/test_automation_ck_relogin.py +++ b/tests/test_automation_ck_relogin.py @@ -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): diff --git a/web/backend/migrations/versions/20261003_0042_account_lifecycle.py b/web/backend/migrations/versions/20261003_0042_account_lifecycle.py index 33fa98c..a8b3907 100644 --- a/web/backend/migrations/versions/20261003_0042_account_lifecycle.py +++ b/web/backend/migrations/versions/20261003_0042_account_lifecycle.py @@ -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 diff --git a/web/backend/permissions.py b/web/backend/permissions.py index 6f5f754..ed64f98 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -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", diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 666dfe2..0b87c6f 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -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), diff --git a/web/backend/routers/automation.py b/web/backend/routers/automation.py index c3a5d90..1b56a91 100644 --- a/web/backend/routers/automation.py +++ b/web/backend/routers/automation.py @@ -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() ) diff --git a/web/backend/routers/cookies.py b/web/backend/routers/cookies.py index 7d72fd3..0fc6696 100644 --- a/web/backend/routers/cookies.py +++ b/web/backend/routers/cookies.py @@ -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, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index cd513b8..56dabbd 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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, } diff --git a/web/backend/services/account_events_service.py b/web/backend/services/account_events_service.py index c159292..6d4eedf 100644 --- a/web/backend/services/account_events_service.py +++ b/web/backend/services/account_events_service.py @@ -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" # 生命周期状态变迁(停用/归档/失效…) # 有符号金额的事件类型(用于页面「累计充值/累计消耗」): # 充值为正、兑换为负,其余不计入金额统计。 diff --git a/web/backend/services/account_lifecycle.py b/web/backend/services/account_lifecycle.py new file mode 100644 index 0000000..eef9c42 --- /dev/null +++ b/web/backend/services/account_lifecycle.py @@ -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}" + ) diff --git a/web/backend/services/automation/jobs/ck_health.py b/web/backend/services/automation/jobs/ck_health.py index 5a34406..57d4bc4 100644 --- a/web/backend/services/automation/jobs/ck_health.py +++ b/web/backend/services/automation/jobs/ck_health.py @@ -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 时记, # 避免每轮巡检都给同一个账号刷屏。 diff --git a/web/backend/services/automation/jobs/ck_relogin.py b/web/backend/services/automation/jobs/ck_relogin.py index e5c0b49..2fbd829 100644 --- a/web/backend/services/automation/jobs/ck_relogin.py +++ b/web/backend/services/automation/jobs/ck_relogin.py @@ -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 diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index 6e2ca38..6d88980 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -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']}" diff --git a/web/backend/services/login_service.py b/web/backend/services/login_service.py index 4a7106e..4d7eb2b 100644 --- a/web/backend/services/login_service.py +++ b/web/backend/services/login_service.py @@ -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, diff --git a/web/frontend/src/api/accounts.ts b/web/frontend/src/api/accounts.ts index f2884d1..ae5166b 100644 --- a/web/frontend/src/api/accounts.ts +++ b/web/frontend/src/api/accounts.ts @@ -17,7 +17,7 @@ import type { export const accountApi = { list: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean; include_sensitive?: boolean }) => api.get('/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>('/accounts', { params }), summary: () => api.get('/accounts/summary'), import: (text: string, tag?: string) => @@ -40,6 +40,12 @@ export const accountApi = { api.delete('/accounts/batch/delete', { params: { account_ids: account_ids.join(',') } }), batchDeleteSelection: (data: AccountBulkSelection) => api.post('/accounts/batch-delete', data), + // 生命周期:disabled / archived / auto(恢复自动推导) + setLifecycle: (id: number, status: string, reason = '') => + api.put(`/accounts/${id}/lifecycle`, { status, reason }), + // 自动化开关:false = 账号可用但自动化不碰它 + setAutomation: (id: number, auto_managed: boolean) => + api.put(`/accounts/${id}/automation`, { auto_managed }), // 详情页:状态卡 + 统计 overview: (id: number) => api.get(`/accounts/${id}/overview`), // 时间线(倒序分页),event_type 为空表示全部 diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 6f3f252..a2304c3 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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 { diff --git a/web/frontend/src/pages/AccountDetailPage.tsx b/web/frontend/src/pages/AccountDetailPage.tsx index 5f34b44..710a567 100644 --- a/web/frontend/src/pages/AccountDetailPage.tsx +++ b/web/frontend/src/pages/AccountDetailPage.tsx @@ -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 = { tag_changed: { label: '标签', color: 'default' }, }; +/** 生命周期状态 → 展示色(与账号列表页保持一致,取值域见后端 account_lifecycle)。 */ +const STATUS_TONES: Record = { + 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 {style.label}; @@ -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 (
- + + {status === 'disabled' || status === 'archived' ? ( + void handleLifecycle('auto')} + > + + + ) : ( + <> + void handleLifecycle('disabled')} + > + + + void handleLifecycle('archived')} + > + + + + )} + {overview?.auto_managed === false ? ( + + ) : ( + void handleAutomation(false)} + > + + + )} @@ -161,6 +229,9 @@ export default function AccountDetailPage() { title={ {overview?.username || '—'} + + {overview?.status_label || '—'} + {overview?.tag && {overview.tag}} {overview?.auto_managed === false && 不参与自动化} @@ -177,6 +248,12 @@ export default function AccountDetailPage() { {overview?.bind_status || '-'} {overview?.remark || '-'} + + {formatTime(overview?.status_changed_at)} + {overview?.status_reason ? ( + ({overview.status_reason}) + ) : null} +
diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index 141a666..f4f3074 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -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 = { + 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(''); + const [statusFilter, setStatusFilter] = useState(''); const [searchText, setSearchText] = useState(''); const [selectedRowKeys, setSelectedRowKeys] = useState([]); 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() { ), }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: (status: string, record: AccountItem) => { + const tone = STATUS_TONES[status] ?? 'default'; + return ( + + {record.status_label || status || '-'} + {record.auto_managed === false && 不自动} + + ); + }, + }, { title: '标签', dataIndex: 'tag', @@ -358,6 +398,18 @@ export default function AccountsPage() { options={tags.map((t) => ({ value: t, label: t }))} prefix={} /> +