Files
live-hub-py/tests/test_account_actions.py
T
yml2213 0a922ce56d feat(account): S4 事件发射点补齐 —— 审计与时间线打通、批量事件、ck_invalid 只认转变
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` 目前没有改备注的接口,故无发射点(已在文档标注)。
2026-10-04 15:09:46 +08:00

416 lines
15 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.
"""S4 回归:账号操作的事件发射与审计关联。
S4 之前的状态(都是「定义了却从没发过」):
- `assigned` / `tag_changed` 两个事件类型**定义了、前端也渲染了,但没有任何代码发**;
- 分配/改标签的审计**不带 `account_id`**(`target` 是自由文本,账号改名就关联不上);
- 清 CK 不发事件 —— 而这是**不可逆**操作,时间线里却不留痕;
- `ck_invalid` **每轮巡检都发**:注释写着「避免每轮刷屏」,代码却是一个失效一周的
账号攒 28 条重复事件。
本文件守住修复后的行为。
"""
from datetime import UTC, datetime
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AccountEvent, AuditLog, User
from web.backend.services import account_events_service as svc
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.admin = User(
username="act-admin", password_hash="h", role="super_admin", is_active=True
)
self.support = User(
username="act-kf", password_hash="h", role="support", is_active=True
)
self.session.add_all([self.admin, self.support])
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",
cookie=kwargs.pop("cookie", "acf_uid=1"),
ck_check_status=kwargs.pop("ck_check_status", "valid"),
**kwargs,
)
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()
)
def latest_audit(self, action: str) -> AuditLog | None:
return (
self.session.query(AuditLog)
.filter(AuditLog.action == action)
.order_by(AuditLog.id.desc())
.first()
)
class TestAssignEmitsEvent(_Base):
def test_single_assign_writes_event_and_links_audit(self):
from web.backend.routers.accounts import assign_account
from web.backend.schemas import AccountAssign
account = self.make_account("a")
assign_account(
account.id,
AccountAssign(assigned_to=self.support.id),
db=self.session,
current=self.admin,
)
self.session.refresh(account)
assert account.assigned_to == self.support.id
events = self.events(account.id, svc.EVENT_ASSIGNED)
assert len(events) == 1
assert events[0].category == svc.CATEGORY_OPS
assert "act-kf" in events[0].title
assert events[0].actor_type == "user"
assert events[0].actor_id == self.admin.id
# 审计也要能反查到账号(以前只写自由文本 target)
audit = self.latest_audit("account:assign")
assert audit is not None and audit.account_id == account.id
def test_unassign_writes_event(self):
from web.backend.routers.accounts import assign_account
from web.backend.schemas import AccountAssign
account = self.make_account("a", assigned_to=self.support.id)
assign_account(
account.id,
AccountAssign(assigned_to=None),
db=self.session,
current=self.admin,
)
events = self.events(account.id, svc.EVENT_ASSIGNED)
assert len(events) == 1
assert "取消分配" in events[0].title
def test_batch_assign_emits_one_event_per_account(self):
from web.backend.routers.accounts import batch_assign_accounts
from web.backend.schemas import BatchAssign
accounts = [self.make_account(f"a{i}") for i in range(3)]
batch_assign_accounts(
BatchAssign(
account_ids=[a.id for a in accounts], assigned_to=self.support.id
),
db=self.session,
current=self.admin,
)
for account in accounts:
self.session.refresh(account)
assert account.assigned_to == self.support.id
assert len(self.events(account.id, svc.EVENT_ASSIGNED)) == 1
class TestTagEmitsEvent(_Base):
def test_single_tag_writes_event_with_from_to(self):
from web.backend.routers.accounts import set_account_tag
from web.backend.schemas import AccountTag
account = self.make_account("a", tag="旧标签")
set_account_tag(
account.id, AccountTag(tag="新标签"), db=self.session, current=self.admin
)
self.session.refresh(account)
assert account.tag == "新标签"
events = self.events(account.id, svc.EVENT_TAG_CHANGED)
assert len(events) == 1
assert events[0].detail == {"from": "旧标签", "to": "新标签"}
assert events[0].category == svc.CATEGORY_OPS
audit = self.latest_audit("account:tag")
assert audit is not None and audit.account_id == account.id
def test_batch_tag_emits_per_account_events(self):
from web.backend.routers.accounts import batch_tag
from web.backend.schemas import AccountTag
accounts = [self.make_account(f"a{i}") for i in range(3)]
batch_tag(
AccountTag(tag="批量标签", account_ids=[a.id for a in accounts]),
db=self.session,
current=self.admin,
)
for account in accounts:
self.session.refresh(account)
assert account.tag == "批量标签"
events = self.events(account.id, svc.EVENT_TAG_CHANGED)
assert len(events) == 1
assert events[0].detail["to"] == "批量标签"
class TestCkCleared(_Base):
def test_single_clear_emits_event_and_audit_link(self):
from web.backend.routers.cookies import delete_cookie
account = self.make_account("a")
delete_cookie(account.id, db=self.session, current=self.admin)
self.session.refresh(account)
assert account.cookie == ""
events = self.events(account.id, svc.EVENT_CK_CLEARED)
assert len(events) == 1
assert events[0].category == svc.CATEGORY_AUTH
audit = self.latest_audit("cookie:delete")
assert audit is not None and audit.account_id == account.id
def test_batch_clear_emits_event_per_account(self):
from web.backend.routers.cookies import delete_cookies_batch
accounts = [self.make_account(f"a{i}") for i in range(3)]
delete_cookies_batch(
account_ids=",".join(str(a.id) for a in accounts),
db=self.session,
current=self.admin,
)
for account in accounts:
self.session.refresh(account)
assert account.cookie == ""
assert len(self.events(account.id, svc.EVENT_CK_CLEARED)) == 1
class TestCkInvalidOnlyOnTransition(_Base):
"""`ck_invalid` 是**一次状态转变**,不是每轮读数。
旧实现每轮巡检只要判定 invalid 就发一条(源码注释却写着「避免每轮刷屏」)——
6 小时一轮、失效一周 = 28 条一模一样的事件,把时间线淹掉。
"""
def _job(self):
from web.backend.models import AutomationJob
from web.backend.services.automation.jobs import ck_health
return AutomationJob(
key=ck_health.JOB_KEY, interval_seconds=6 * 3600, batch_size=200
)
def _run(self, message="鱼丸接口: 账户登陆异常,请重新登陆"):
"""跑一轮巡检。
⚠️ 先清掉 `ck_checked_at`:候选查询会过滤「距上次检测超过作业间隔」,
interval 最小 60 秒,同一轮里刚查过的账号**根本不会被再次选中** ——
不重置的话这个用例是假绿的(跑三轮其实只跑了一轮)。
"""
from web.backend.services.automation.jobs import ck_health
self.session.query(Account).update(
{Account.ck_checked_at: None}, synchronize_session=False
)
self.session.commit()
original = ck_health.check_douyu_cookie
ck_health.check_douyu_cookie = lambda _c: {
"valid": False,
"message": message,
"fish_ball": None,
"nickname": None,
"level": None,
"checked_at": NOW,
}
try:
return ck_health.run_ck_health(self.session, self._job())
finally:
ck_health.check_douyu_cookie = original
def test_repeated_invalid_rounds_emit_only_once(self):
account = self.make_account("a", ck_check_status="valid")
self._run()
self._run()
self._run()
assert len(self.events(account.id, svc.EVENT_CK_INVALID)) == 1
def test_recovering_then_failing_again_emits_a_new_event(self):
"""由好变坏 → 恢复 → 再变坏,应当各记一条(是两次转变)。"""
from web.backend.services.account_lifecycle import on_ck_verdict
account = self.make_account("a", ck_check_status="valid")
self._run()
assert len(self.events(account.id, svc.EVENT_CK_INVALID)) == 1
# 恢复(巡检判 valid)
from web.backend.services.automation.jobs import ck_health
original = ck_health.check_douyu_cookie
ck_health.check_douyu_cookie = lambda _c: {
"valid": True,
"message": "ok",
"fish_ball": 1,
"nickname": "n",
"level": 1,
"checked_at": NOW,
}
self.session.query(Account).update(
{Account.ck_checked_at: None}, synchronize_session=False
)
self.session.commit()
try:
ck_health.run_ck_health(self.session, self._job())
finally:
ck_health.check_douyu_cookie = original
# 再失效
self._run()
assert len(self.events(account.id, svc.EVENT_CK_INVALID)) == 2
assert account.id # 避免未使用告警
# error(查不到)不改变结论、也不发事件
on_ck_verdict(self.session, account, "error", detail={}, occurred_at=NOW)
self.session.commit()
assert len(self.events(account.id, svc.EVENT_CK_INVALID)) == 2
class TestEmitBulk(_Base):
def test_writes_many_and_returns_count(self):
accounts = [self.make_account(f"a{i}") for i in range(5)]
written = svc.emit_bulk(
self.session,
[
{
"account_id": acc.id,
"event_type": svc.EVENT_TAG_CHANGED,
"title": f"批量 {acc.username}",
"category": svc.CATEGORY_OPS,
}
for acc in accounts
],
)
self.session.commit()
assert written == 5
for acc in accounts:
assert len(self.events(acc.id, svc.EVENT_TAG_CHANGED)) == 1
def test_skips_invalid_rows_and_never_raises(self):
account = self.make_account("a")
written = svc.emit_bulk(
self.session,
[
{"account_id": None, "event_type": "x", "title": "无账号"},
{"account_id": account.id, "event_type": "", "title": "无类型"},
{
"account_id": account.id,
"event_type": svc.EVENT_TAG_CHANGED,
"title": "有效",
},
],
)
self.session.commit()
assert written == 1
def test_empty_input_is_noop(self):
assert svc.emit_bulk(self.session, []) == 0
class TestEventsCategoryFilter(_Base):
def test_filter_by_category(self):
from web.backend.routers.accounts import account_events
account = self.make_account("a")
svc.emit(
self.session,
account_id=account.id,
event_type=svc.EVENT_TAG_CHANGED,
title="改标签",
category=svc.CATEGORY_OPS,
)
svc.emit(
self.session,
account_id=account.id,
event_type=svc.EVENT_CK_INVALID,
title="失效",
category=svc.CATEGORY_AUTH,
)
self.session.commit()
def titles(**kwargs):
page = account_events(
account.id,
page=1,
page_size=50,
db=self.session,
current=self.admin,
**kwargs,
)
return [item["title"] for item in page["items"]]
assert set(titles(category="")) == {"改标签", "失效"}
assert titles(category=svc.CATEGORY_OPS) == ["改标签"]
assert titles(category=svc.CATEGORY_AUTH) == ["失效"]
# 事件类型筛选仍然可用
assert titles(event_type=svc.EVENT_CK_INVALID) == ["失效"]
def test_response_carries_ledger_fields(self):
from web.backend.routers.accounts import account_events
account = self.make_account("a")
svc.emit(
self.session,
account_id=account.id,
event_type=svc.EVENT_EXCHANGE,
title="兑换 兔兔白日梦",
category=svc.CATEGORY_BUSINESS,
subject_type="commodity",
subject_key="sku-1",
actor_type="user",
actor_id=self.admin.id,
)
self.session.commit()
page = account_events(
account.id, page=1, page_size=50, db=self.session, current=self.admin
)
item = page["items"][0]
assert item["category"] == svc.CATEGORY_BUSINESS
assert item["subject_key"] == "sku-1"
assert item["actor_id"] == self.admin.id
class TestBatchDeleteRecordsIds(_Base):
def test_delete_audit_keeps_account_ids_for_forensics(self):
"""删账号会**连带删掉它的事件时间线**,所以审计是唯一痕迹。"""
from web.backend.routers.accounts import batch_delete_accounts
accounts = [self.make_account(f"a{i}") for i in range(3)]
ids = [a.id for a in accounts]
batch_delete_accounts(
account_ids=",".join(str(i) for i in ids),
db=self.session,
current=self.admin,
)
audit = self.latest_audit("account:delete")
assert audit is not None
assert audit.detail is not None
for account_id in ids:
assert str(account_id) in audit.detail