Files
live-hub-py/tests/test_account_lifecycle.py
T
yml2213 1a8710834a 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 账号页重构。
2026-10-04 14:48:22 +08:00

658 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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