S3 让 `status` 可信,S4 让**时间线完整**:账号上发生的事,时间线里都能查到。 ## 补齐「定义了却从没发过」的事件 `assigned` / `tag_changed` 这两个类型早就定义了、前端 `EVENT_STYLE` 也渲染了, 但**全仓没有任何代码发过**(`grep` 只有定义处)。本次接上: - 单个分配 / 批量分配 → `assigned`(detail 带 assigned_to / assigned_username) - 单个改标签 / 批量改标签 / 按筛选批量改标签 → `tag_changed`(detail 带 from/to) ## 新增 `ck_cleared`,并修掉 `ck_invalid` 的刷屏 - **`ck_cleared`**:清 CK 是**不可逆**操作(重登当前成功率极低),时间线里却不留痕。 现在单个清除与批量清除共用 `account_lifecycle.on_ck_cleared()`。 - **`ck_invalid` 改为只在「由好变坏」时发**。旧实现每轮巡检只要判定 invalid 就发一条, 源码注释写着「避免每轮刷屏」,**但代码并没有做到** —— 6 小时一轮、失效一周就是 28 条一模一样的事件。判定失效是**一次状态转变**,不是每轮读数。`error`(查不到) 同样不改变结论、不发事件。 ## 审计与时间线一次写完(防漏一边) 新增 `audit_service.record_account_action()`:**审计 + 账号事件一起写**,并把 `account_id` 落到审计上。这两件事必须成对出现 —— 只写审计,时间线就缺「谁改了什么」; 只发事件,合规审计就缺一条。分成两次调用写,迟早有人漏一边(`assigned` 就是这么漏的)。 顺带把批量删除的审计补上被删账号 id(`detail.account_ids`,截前 200 条): 删账号会**连带删掉它的事件时间线**(外键),审计是唯一痕迹 —— 这正是 10-03 那次事故要留的东西。 ## 批量操作不再逐条开 SAVEPOINT 批量分配/改标签可能覆盖几千个账号,而每个账号的时间线都该各留一条。 逐条走 `emit()` 会给每条开一个 SAVEPOINT,几千条时请求明显变慢。 新增 `emit_bulk()`:一次 executemany,整批包在**一个** SAVEPOINT 里, 与 `emit()` 一样**绝不抛异常**(整批失败就整批丢弃 + warning,不影响业务写入)。 ## 检测入口收口:4 处 → 1 个 `on_ck_verdict()` `ck_health` / 人工检测 / 工作台执行前检测 / 重登后复检 原本各自写 `ck_check_status` + `ck_check_result` + `ck_checked_at` + 发事件 + 调 `sync_status`, 口径容易分叉(`ck_invalid` 的刷屏就是这么来的)。现在统一走 `account_lifecycle.on_ck_verdict()`,顺序固定:**写结论 → 由好变坏记事件 → 重算状态**。 至此 `account_lifecycle` 成为**唯一**写生命周期字段的模块,并加了一条**源码扫描用例** 把它钉死(任何地方直接 `acc.status = ...` 都会让它红,并指出 file:line)—— 比写在文档里的约定可靠。虎牙的 `huya_accounts.status` 是同一个病,但属另一套模型, 已在用例里显式排除并注明。 ## 接口与前端 - 时间线支持 `?category=` 筛选(lifecycle / auth / business / ops); 响应补 `category` / `subject_*` / `actor_*` - 前端 `EVENT_STYLE` 补上 `status_changed`(**S3 我漏的**:加了事件类型却没加样式, 页面上显示成原始英文)与 `ck_cleared`;时间线加「分类」筛选 ## 验证 - `TZ=Asia/Shanghai`:**420 passed / 6 skipped**(S3 后 404 → 新增 16 条) - 迁移链 3 passed;ruff check/format 全绿;前端 `tsc -b && vite build` 与 `npm run lint`(0 errors)均通过 - **两条关键用例都做过「反向验证」**(先注入旧行为确认会红): - 把 `ck_invalid` 改回「每轮都发」→ 用例报 `3 == 1`; - 在路由里直接写 `acc.status = ...` → 不变量用例给出精确 file:line。 - 新用例还覆盖:批量分配/改标签逐账号发事件、清 CK 事件与审计关联、 `emit_bulk` 的跳过与容错、时间线分类筛选、批量删除的 id 留痕。 未做(后续):S5 业务事实直发事件 + `douyu_tasks` 保留策略、S6 账号页重构 + `bind_status` 三列拆解。`remark_changed` 目前没有改备注的接口,故无发射点(已在文档标注)。
714 lines
26 KiB
Python
714 lines
26 KiB
Python
"""S3 回归:账号生命周期状态。
|
||
|
||
本文件守住四件事:
|
||
|
||
1. **推导正确**:`derive_status` 是真值表 —— 底层判据的每种组合对应哪个状态;
|
||
2. **人工态优先**:`disabled` / `archived` 不被自动推导冲掉;
|
||
3. **收口幂等**:状态没变时 `sync_status` 不写库、不发事件;
|
||
4. **全生命周期闭环**:导入 → 登录 → 判失效 → 重登 → 转人工 → 人工救回 → 清 CK。
|
||
|
||
外加「停用/归档的账号不参与任何自动化」这条硬约束(S3 之前是个真缺口:
|
||
作业只按底层判据选行,停用的账号照样会被自动重登)。
|
||
"""
|
||
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import ClassVar
|
||
|
||
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
|
||
|
||
|
||
class TestStatusWriteFunnel:
|
||
"""架构不变量:**`account.status` 只能由 `account_lifecycle` 写**。
|
||
|
||
`accounts.bind_status` 就是这么烂掉的 —— 谁都能写、语义没人管,最后 36 种取值
|
||
互相覆盖,连「这账号到底绑没绑」都查不出来。
|
||
|
||
这条用源码扫描把它钉死:任何地方直接给 `status` 赋值都会让用例变红,逼你去用
|
||
`sync_status()`(按判据推导)或 `set_manual_state()`(人工态)。
|
||
比写在文档里的约定可靠 —— 约定会被忘记,红了的用例不会。
|
||
"""
|
||
|
||
#: 允许直接写 status 的地方(模型定义本体、收口模块、以及迁移脚本的历史回填)
|
||
ALLOWED: ClassVar[frozenset[str]] = frozenset(
|
||
{
|
||
"web/backend/models.py",
|
||
"web/backend/services/account_lifecycle.py",
|
||
}
|
||
)
|
||
|
||
def test_only_lifecycle_module_writes_account_status(self):
|
||
import re
|
||
from pathlib import Path
|
||
|
||
root = Path(__file__).resolve().parents[1]
|
||
backend = root / "web" / "backend"
|
||
patterns = (
|
||
re.compile(r"\b(?:account|acc)\.status\s*=(?!=)"),
|
||
# 批量更新:update({Account.status: ...})
|
||
re.compile(r"\{\s*Account\.status\s*:"),
|
||
# bulk update 的多行写法
|
||
re.compile(r"^\s*\{\s*Account\.status\s*,"),
|
||
)
|
||
offenders: list[str] = []
|
||
for path in backend.rglob("*.py"):
|
||
rel = path.relative_to(root).as_posix()
|
||
# 虎牙是**另一套账号模型**(`huya_accounts.status` 自由文本,
|
||
# 20+ 种取值当状态机用,与这里的 bind_status 是同一个病)。
|
||
# 那一摊单独治理,不在本不变量范围内。
|
||
if rel in self.ALLOWED or "/migrations/" in rel or "huya" in rel:
|
||
continue
|
||
for lineno, line in enumerate(
|
||
path.read_text(encoding="utf-8").splitlines(), 1
|
||
):
|
||
if line.lstrip().startswith("#"):
|
||
continue
|
||
if any(p.search(line) for p in patterns):
|
||
offenders.append(f"{rel}:{lineno}: {line.strip()}")
|
||
|
||
assert not offenders, (
|
||
"account.status 只能由 account_lifecycle 写(bind_status 的教训):\n"
|
||
"请改用 sync_status() / set_manual_state()。违规点:\n"
|
||
+ "\n".join(offenders)
|
||
)
|