`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 账号页重构。
658 lines
24 KiB
Python
658 lines
24 KiB
Python
"""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
|