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` 目前没有改备注的接口,故无发射点(已在文档标注)。
This commit is contained in:
yml2213
2026-10-04 15:09:46 +08:00
parent fbab805610
commit 0a922ce56d
14 changed files with 967 additions and 184 deletions
+25 -16
View File
@@ -256,21 +256,30 @@ CREATE INDEX ix_account_events_ledger
**事件类型表(收敛后的完整取值域)**:
| category | event_type | 含义 | amount |
| --- | --- | --- | --- |
| lifecycle | `imported` | 导入 | — |
| lifecycle | `status_changed` | **所有**状态变迁(detail 带 from/to/reason),停用与归档不再单列事件类型 | — |
| auth | `login_ok` | 登录成功(拿到新 CK) | — |
| auth | `ck_invalid` | 巡检判定 CK 失效 | — |
| auth | `relogin_ok` / `relogin_failed` | 自动重登结果 | — |
| auth | `ck_cleared` | CK 被清除(人工或检测判定) | — |
| business | `recharge` | 充值到账 | + ¥ |
| business | `exchange` | 兑换(含消耗积分) | − 积分 |
| business | `points_credit` | 积分到账 | + 积分 |
| business | `points_expire` | 积分到期(由 `willExpireTime` 派生) | — |
| business | `elite_opened` / `esports_opened` | 开通精英宝典 / 电竞手册 | — |
| business | `bind_ok` / `rebind` | 角色绑定 / 换绑 | — |
| ops | `assigned` / `tag_changed` / `remark_changed` | 人工操作 | — |
| category | event_type | 含义 | 发射点 | amount |
| --- | --- | --- | --- | --- |
| lifecycle | `status_changed` | **所有**状态变迁(detail 带 from/to/reason),停用与归档不再单列事件类型 | `account_lifecycle._apply`(唯一) | — |
| auth | `login_ok` | 登录成功(拿到新 CK) | `login_service._write_login_success` | — |
| auth | `ck_invalid` | CK **由好变坏**(转变才记,见下) | `account_lifecycle.on_ck_verdict` | — |
| auth | `ck_cleared` | CK 被清除(**不可逆**,必须留痕) | `account_lifecycle.on_ck_cleared` | — |
| auth | `relogin_ok` / `relogin_failed` | 自动重登结果 | `login_service` / `ck_relogin.settle_results` | — |
| ops | `assigned` | 分配 / 取消分配 | `audit_service.record_account_action`、批量走 `emit_bulk` | — |
| ops | `tag_changed` | 标签变更(detail 带 from/to) | 同上 | — |
| business | `recharge` | 充值到账 | 收集器(S5) | + ¥ |
| business | `exchange` | 兑换(含消耗积分) | 收集器(S5) | − 积分 |
| business | `points_credit` | 积分到账 | 收集器(S5) | + 积分 |
| business | `elite_opened` / `esports_opened` | 开通精英宝典 / 电竞手册 | `douyu_runner_manual` | — |
| business | `bind_ok` / `rebind` | 角色绑定 / 换绑 | 收集器(S5) | — |
> **不落的两个**:
> - `imported`:导入时间直接读 `accounts.created_at`,不落表(设计 §3.5 起就这么定);
> - `remark_changed`:**目前没有改备注的接口**,所以这个类型没有发射点。要么补接口,
> 要么从表里删掉 —— 留着一个永远不发的类型只会让人以为它有数据。
>
> **`ck_invalid` 只认「转变」**(2026-10-04 S4 修正):旧实现每轮巡检只要判定 invalid
> 就发一条,注释写着「避免每轮刷屏」而代码并没有做到 —— 6 小时一轮、失效一周 =
> 28 条一模一样的事件,把时间线淹掉。判定失效是**一次状态转变**,不是每轮读数。
> 同理 `error`(查不到)不改变结论、也不发事件。
**「开通 / 积分 / 兑换记录」怎么呈现**:就是这张表按 `category` 或 `event_type` 过滤的视图,
**不另建三张表**。理由:
@@ -429,7 +438,7 @@ WHERE a.cookie <> src.cookie;
| **S1** | 迁移 0042:加列 + 回填 + 自校验;`accounts` 成为状态载体 | 账号能独立描述登录态 | — |
| **S2** | 读写一次切换:12 个文件、24 读点 / 10 写点全部改 `LoginTask.*` → `Account.*`;批次行复用逻辑重写;在途子查询 + 索引 | 登录记录与 CK 彻底解耦 | S1 |
| **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 |
| **S4** ✅ | 事件发射点补齐(**已完成 2026-10-04**):`assigned` / `tag_changed` 接上(此前定义了却从没发过)、新增 `ck_cleared`、`ck_invalid` 改为只在转变时发;`audit_logs.account_id` 在分配/标签/检测/清 CK 处打通;新增 `emit_bulk`(批量操作逐账号写事件)与 `record_account_action`(审计+事件一次写完);时间线支持 `category` 筛选;前端补 `status_changed` / `ck_cleared` 样式与分类筛选 | 时间线完整 | S2 |
| **S5** | 业务事实直发事件:开通 / 充值 / 积分 / 兑换 / 绑定在任务成功时写事件;`douyu_tasks` 加保留策略 | 台账可用,687MB 可控 | S4 |
| **S6** | 账号页重构:列表加状态与关键列;详情页做成「生命周期主页」(状态卡 + 台账 + 时间线 + 操作);`bind_status` 三列拆解落地 | 账号管理真正独立 | S3、S5 |
| **S7** | 账号检测并入账号域(`account-check` 从「粘贴文本」改为「按账号选」,结果落事件) | 检测不再是孤岛 | S6 |
+415
View File
@@ -0,0 +1,415 @@
"""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
+56
View File
@@ -12,6 +12,7 @@
"""
from datetime import UTC, datetime, timedelta
from typing import ClassVar
import pytest
@@ -655,3 +656,58 @@ class TestLifecycleApi(_Base):
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)
)
+134 -50
View File
@@ -30,11 +30,15 @@ from ..schemas import (
BatchAssign,
)
from ..services.account_events_service import (
CATEGORY_OPS,
EVENT_ASSIGNED,
EVENT_CK_INVALID,
EVENT_ELITE_OPENED,
EVENT_ESPORTS_OPENED,
EVENT_EXCHANGE,
EVENT_RECHARGE,
EVENT_TAG_CHANGED,
emit_bulk,
)
from ..services.account_lifecycle import (
STATUS_ARCHIVED,
@@ -45,6 +49,7 @@ from ..services.account_service import (
cookie_account_ids_query,
parse_and_build_accounts,
)
from ..services.audit_service import record_account_action, record_audit
from ..services.douyu_service import cookie_holding_filter
router = APIRouter(prefix="/api/accounts", tags=["账号管理"])
@@ -336,13 +341,16 @@ def assign_account(
db.query(DouyuTask).filter(DouyuTask.account_id == acc.id).update(
{DouyuTask.account_assigned_to: req.assigned_to}, synchronize_session=False
)
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="account:assign",
target=acc.username,
)
target = db.query(User).filter(User.id == req.assigned_to).first()
target_name = target.username if target else ""
record_account_action(
db,
current,
account=acc,
action="account:assign",
event_type=EVENT_ASSIGNED,
title=f"分配给 {target_name}" if target_name else "取消分配",
detail={"assigned_to": req.assigned_to, "assigned_username": target_name},
)
db.commit()
return {"message": "已分配", "success": True}
@@ -389,6 +397,8 @@ def batch_assign_accounts(
detail=f"以下账号尚未成功登录,无法分配:{name_list}{suffix}",
)
# 先取出账号对象(批量时间线要逐个写事件,光有 count 不够)
target_accounts = db.query(Account).filter(Account.id.in_(req.account_ids)).all()
count = (
db.query(Account)
.filter(Account.id.in_(req.account_ids))
@@ -397,13 +407,40 @@ def batch_assign_accounts(
db.query(DouyuTask).filter(DouyuTask.account_id.in_(req.account_ids)).update(
{DouyuTask.account_assigned_to: req.assigned_to}, synchronize_session=False
)
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="account:assign",
target=f"批量{'分配' if req.assigned_to else '取消分配'}{count}个账号",
)
target_user = (
db.query(User).filter(User.id == req.assigned_to).first()
if req.assigned_to is not None
else None
)
target_name = target_user.username if target_user else ""
action_word = "分配" if req.assigned_to else "取消分配"
title = f"批量分配:{target_name}" if target_name else "批量取消分配"
record_audit(
db,
current,
action="account:assign",
target=f"批量{action_word}{count}个账号",
detail={"assigned_to": req.assigned_to, "assigned_username": target_name},
)
# 每个账号的时间线各留一条 —— 「这个账号什么时候被分配给谁」是账号级事实。
emit_bulk(
db,
[
{
"account_id": acc.id,
"event_type": EVENT_ASSIGNED,
"title": title,
"category": CATEGORY_OPS,
"detail": {
"assigned_to": req.assigned_to,
"assigned_username": target_name,
},
"source": "audit",
"actor_type": "user",
"actor_id": current.id,
}
for acc in target_accounts
],
)
db.commit()
action = "分配" if req.assigned_to else "取消分配"
@@ -526,6 +563,45 @@ def set_account_automation(
}
def _apply_tag_bulk(db: Session, current: User, ids: list[int], tag: str) -> None:
"""批量改标签:更新 + 审计 + **逐账号写事件**。
先取出对象再更新:`bulk update` 不加载实体,而每个账号的时间线都要各留一条
「标签从什么改成了什么」——这正是本仓库以前的缺口(`tag_changed` 事件类型
定义了、前端也渲染了,却从来没有代码发过)。
"""
accounts = _visible_accounts_query(db, current).filter(Account.id.in_(ids)).all()
if not accounts:
return
previous = {acc.id: (acc.tag or "") for acc in accounts}
db.query(Account).filter(Account.id.in_([a.id for a in accounts])).update(
{Account.tag: tag}, synchronize_session=False
)
record_audit(
db,
current,
action="account:tag",
target=f"批量设置标签「{tag}」{len(accounts)} 个账号",
detail={"to": tag},
)
emit_bulk(
db,
[
{
"account_id": acc.id,
"event_type": EVENT_TAG_CHANGED,
"title": f"标签:{previous[acc.id] or '(空)'} → {tag or '(空)'}",
"category": CATEGORY_OPS,
"detail": {"from": previous[acc.id], "to": tag},
"source": "audit",
"actor_type": "user",
"actor_id": current.id,
}
for acc in accounts
],
)
@router.put("/{account_id}/tag")
def set_account_tag(
account_id: int,
@@ -538,15 +614,16 @@ def set_account_tag(
acc = _visible_accounts_query(db, current).filter(Account.id == account_id).first()
if not acc:
raise HTTPException(status_code=404, detail="账号不存在")
old_tag = acc.tag or ""
acc.tag = (req.tag or "").strip()
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="account:tag",
target=acc.username,
detail=acc.tag,
)
record_account_action(
db,
current,
account=acc,
action="account:tag",
event_type=EVENT_TAG_CHANGED,
title=f"标签:{old_tag or '(空)'} → {acc.tag or '(空)'}",
detail={"from": old_tag, "to": acc.tag},
)
db.commit()
return {"message": "标签已更新", "success": True}
@@ -563,11 +640,8 @@ def batch_tag(
if not req.account_ids:
raise HTTPException(status_code=400, detail="请选择账号")
tag = (req.tag or "").strip()
count = (
_visible_accounts_query(db, current)
.filter(Account.id.in_(req.account_ids))
.update({Account.tag: tag}, synchronize_session=False)
)
_apply_tag_bulk(db, current, req.account_ids, tag)
count = len(req.account_ids)
db.commit()
return {"message": f"已为 {count} 个账号设置标签", "success": True}
@@ -584,11 +658,8 @@ def batch_tag_selection(
if not ids:
raise HTTPException(status_code=400, detail="请选择账号")
tag = (req.tag_value or "").strip()
count = (
_visible_accounts_query(db, current)
.filter(Account.id.in_(ids))
.update({Account.tag: tag}, synchronize_session=False)
)
_apply_tag_bulk(db, current, ids, tag)
count = len(ids)
db.commit()
scope = "当前筛选下" if req.all_matching else "选中的"
return {
@@ -644,13 +715,15 @@ def batch_delete_accounts(
db.query(Account).filter(Account.id.in_(ids)).delete(synchronize_session=False)
)
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="account:delete",
target=f"批量删除{deleted}个账号",
)
# 账号删除会**连带删掉它的事件时间线**(外键),所以留下的痕迹只有审计。
# 批量删时 account_id 无法表达 N 个账号,改成把删掉的 id 记进 detail ——
# 出了事故才知道删了哪些,正是本项目 10-03 那次事故的教训。
record_audit(
db,
current,
action="account:delete",
target=f"批量删除{deleted}个账号",
detail={"account_ids": ids[:200], "total_ids": len(ids), "deleted": deleted},
)
db.commit()
return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True}
@@ -681,13 +754,12 @@ def batch_delete_accounts_selection(
.filter(Account.id.in_(ids))
.delete(synchronize_session=False)
)
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="account:delete",
target=f"{'按筛选' if req.all_matching else '批量'}删除{deleted}个账号",
)
record_audit(
db,
current,
action="account:delete",
target=f"{'按筛选' if req.all_matching else '批量'}删除{deleted}个账号",
detail={"account_ids": ids[:200], "total_ids": len(ids), "deleted": deleted},
)
db.commit()
return {"message": f"已删除 {deleted} 个账号", "deleted": deleted, "success": True}
@@ -809,12 +881,18 @@ def account_events(
page: int = 1,
page_size: int = 50,
event_type: str = "",
category: str = Query(
"", description="按粗分类筛选:lifecycle / auth / business / ops"
),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""账号时间线(倒序分页)。
权限沿用账号可见性:客服只能看自己名下账号的事件。
`category` 是给页面用的「大分类」过滤(状态变迁 / 登录与 CK / 业务 / 人工操作),
比逐一点 `event_type` 好用:客服最常看的是「人工操作」与「状态变迁」两条线。
"""
acc = _visible_accounts_query(db, current).filter(Account.id == account_id).first()
if not acc:
@@ -824,9 +902,14 @@ def account_events(
page_size = max(1, min(int(page_size), 200))
where = "WHERE account_id = :aid"
params: dict[str, Any] = {"aid": account_id}
if event_type:
sql_event_type = _query_str(event_type)
sql_category = _query_str(category)
if sql_event_type:
where += " AND event_type = :etype"
params["etype"] = event_type
params["etype"] = sql_event_type
if sql_category:
where += " AND category = :category"
params["category"] = sql_category
total = (
db.execute(
@@ -839,8 +922,9 @@ def account_events(
db.execute(
text(
f"""
SELECT id, event_type, occurred_at, title, amount, balance_after,
detail, source
SELECT id, event_type, category, occurred_at, title, amount,
balance_after, subject_type, subject_key, detail, source,
actor_type, actor_id
FROM account_events {where}
ORDER BY occurred_at DESC, id DESC
LIMIT :limit OFFSET :offset
+16 -26
View File
@@ -33,7 +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.account_lifecycle import on_ck_cleared, on_ck_verdict
from ..services.cookie_check_service import check_douyu_cookie
from ..services.douyu_service import (
LOGIN_ATTEMPT_IN_FLIGHT_STATUSES,
@@ -513,17 +513,15 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d
acc = accounts_by_id.get(item["id"])
if not acc:
continue
acc.ck_check_status = "valid" if item["valid"] else "invalid"
acc.ck_check_result = {
"fish_ball": item.get("fish_ball"),
"nickname": item.get("nickname"),
"level": item.get("level"),
"message": item.get("message", ""),
}
acc.ck_checked_at = checked_at
# 人工检测同样改变生命周期状态(正常 ↔ CK 失效)
sync_status(
db, acc, occurred_at=checked_at, actor_type="user", actor_id=current.id
# 人工检测结论同样走统一入口(写结论 + 由好变坏记事件 + 重算状态)
on_ck_verdict(
db,
acc,
"valid" if item["valid"] else "invalid",
detail=item,
occurred_at=checked_at,
actor_type="user",
actor_id=current.id,
)
# 审计带上账号:单个检测时可直接反查(批量时只能落到 target 文本里)
db.add(
@@ -787,13 +785,11 @@ def delete_cookies_batch(
)
now = datetime.now(UTC)
for acc in accounts:
acc.cookie = ""
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)
# 清 CK 是**不可逆**操作:清本体 + 清健康状态 + 记事件 + 重算状态,
# 全在 on_ck_cleared 一处(单个清除与批量清除共用)。
on_ck_cleared(
db, acc, occurred_at=now, actor_id=current.id, reason="manual_clear_batch"
)
# 批量清 CK 涉及多个账号,只能落到 target 文本里;单个账号时补 account_id,
# 便于审计页/账号时间线按索引反查。
db.add(
@@ -835,13 +831,7 @@ def delete_cookie(
account_id = acc.id
username = acc.username
now = datetime.now(UTC)
acc.cookie = ""
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)
on_ck_cleared(db, acc, occurred_at=now, actor_id=current.id)
db.add(
AuditLog(
user_id=current.id,
+102 -32
View File
@@ -36,6 +36,7 @@ from sqlalchemy.orm import Session
EVENT_IMPORTED = "imported" # 账号导入(读 accounts.created_at,一般不落表)
EVENT_LOGIN_OK = "login_ok" # 登录成功(含新 CK)
EVENT_CK_INVALID = "ck_invalid" # CK 判定失效
EVENT_CK_CLEARED = "ck_cleared" # CK 被清除(人工清除 / 账号删除前的清理)
EVENT_RELOGIN_OK = "relogin_ok" # 自动重登成功
EVENT_RELOGIN_FAILED = "relogin_failed" # 自动重登失败(转人工/退避)
EVENT_BIND_OK = "bind_ok" # 绑定/换绑
@@ -84,6 +85,60 @@ def _now() -> datetime:
return datetime.now(UTC)
_INSERT_SQL = text(
"""
INSERT INTO account_events (
account_id, event_type, category, occurred_at, title,
amount, balance_after, subject_type, subject_key,
detail, source, actor_type, actor_id, source_ref, created_at
) VALUES (
:account_id, :event_type, :category, :occurred_at, :title,
:amount, :balance_after, :subject_type, :subject_key,
:detail, :source, :actor_type, :actor_id, :source_ref, :created_at
)
"""
)
def _event_params(
*,
account_id: int,
event_type: str,
title: str,
occurred_at: datetime | None = None,
amount: int | None = None,
balance_after: int | None = None,
detail: dict[str, Any] | None = None,
source: str = "manual",
source_ref: str | None = None,
category: str = "",
subject_type: str = "",
subject_key: str = "",
actor_type: str = "",
actor_id: int | None = None,
) -> dict[str, Any]:
"""把一次调用归一成落库参数(`emit` 与 `emit_bulk` 共用,避免 SQL 写两遍)。"""
return {
"account_id": account_id,
"event_type": event_type,
"category": (category or "")[:16],
"occurred_at": to_naive_utc(occurred_at or _now()),
"title": (title or "")[:256],
"amount": amount,
"balance_after": balance_after,
"subject_type": (subject_type or "")[:16],
"subject_key": (subject_key or "")[:96],
"detail": json.dumps(detail, ensure_ascii=False, default=str)
if detail
else None,
"source": source[:24],
"actor_type": (actor_type or "")[:16],
"actor_id": actor_id,
"source_ref": (source_ref or None),
"created_at": to_naive_utc(_now()),
}
def emit(
db: Session,
*,
@@ -117,50 +172,65 @@ def emit(
if not account_id or not event_type:
return False
sql = text(
"""
INSERT INTO account_events (
account_id, event_type, category, occurred_at, title,
amount, balance_after, subject_type, subject_key,
detail, source, actor_type, actor_id, source_ref, created_at
) VALUES (
:account_id, :event_type, :category, :occurred_at, :title,
:amount, :balance_after, :subject_type, :subject_key,
:detail, :source, :actor_type, :actor_id, :source_ref, :created_at
)
"""
params = _event_params(
account_id=account_id,
event_type=event_type,
title=title,
occurred_at=occurred_at,
amount=amount,
balance_after=balance_after,
detail=detail,
source=source,
source_ref=source_ref,
category=category,
subject_type=subject_type,
subject_key=subject_key,
actor_type=actor_type,
actor_id=actor_id,
)
params = {
"account_id": account_id,
"event_type": event_type,
"category": (category or "")[:16],
"occurred_at": to_naive_utc(occurred_at or _now()),
"title": (title or "")[:256],
"amount": amount,
"balance_after": balance_after,
"subject_type": (subject_type or "")[:16],
"subject_key": (subject_key or "")[:96],
"detail": json.dumps(detail, ensure_ascii=False, default=str)
if detail
else None,
"source": source[:24],
"actor_type": (actor_type or "")[:16],
"actor_id": actor_id,
"source_ref": (source_ref or None),
"created_at": to_naive_utc(_now()),
}
try:
# SAVEPOINT:即使这条插入失败(例如 source_ref 撞唯一键),
# 也只回滚它自己,调用方事务里已完成的写入不受影响。
with db.begin_nested():
db.execute(sql, params)
db.execute(_INSERT_SQL, params)
return True
except Exception as exc: # noqa: BLE001 - 旁路,任何失败都不上抛
logger.warning(f"写入账号事件失败(account={account_id} {event_type}):{exc}")
return False
def emit_bulk(db: Session, events: list[dict[str, Any]]) -> int:
"""一次写多条事件,返回实际写入条数。
用于**批量账号操作**(批量分配 / 批量改标签):一次操作可能覆盖几千个账号,
每个账号的时间线都该各留一条「谁在什么时候改了什么」。逐条走 `emit()` 会给
每条开一个 SAVEPOINT,几千条时请求明显变慢 —— 这里改成一次 executemany,
整批包在**一个** SAVEPOINT 里。
与 `emit()` 一样**绝不抛异常**:整批失败就整批丢弃(记 warning),
不会污染调用方的事务,也不会让「批量分配」因为记不上时间线而失败。
每个 dict 的键与 `emit()` 的关键字参数同名(`account_id` / `event_type` /
`title` 必填)。
"""
rows = [
_event_params(**{**item, "occurred_at": item.get("occurred_at")})
for item in events
if item.get("account_id") and item.get("event_type")
]
if not rows:
return 0
try:
with db.begin_nested():
db.execute(_INSERT_SQL, rows)
return len(rows)
except Exception as exc: # noqa: BLE001 - 旁路,任何失败都不上抛
logger.warning(f"批量写入账号事件失败({len(rows)} 条):{exc}")
return 0
# ── douyu_tasks 增量收集 ──────────────────────────────────
# 只收集「业务事件」,例行轮询(query_*)一律不进时间线 —— 它们在 douyu_tasks 里
# 占绝大多数(query_limited_goods 就 6.7 万行),收进去时间线没法看。
+101
View File
@@ -36,7 +36,10 @@ from sqlalchemy.orm import Session
from ..models import Account
from .account_events_service import (
CATEGORY_AUTH,
CATEGORY_LIFECYCLE,
EVENT_CK_CLEARED,
EVENT_CK_INVALID,
EVENT_STATUS_CHANGED,
emit,
)
@@ -248,6 +251,61 @@ def set_manual_state(
)
def on_ck_verdict(
db: Session,
account: Account,
verdict: str,
*,
detail: dict[str, Any] | None = None,
occurred_at: datetime | None = None,
actor_type: str = "system",
actor_id: int | None = None,
) -> None:
"""一次 CK 检测结论落定后的统一处理(**顺序固定**,避免各处自己拼)。
1. 写 `ck_check_status` / `ck_check_result` / `ck_checked_at`;
`error` = 「查不到」,**保留上一次结论** —— 查不到不等于失效;
2. **由好变坏**的那一刻记一条 `ck_invalid` 事件;
3. 重算生命周期状态。
第 2 步只认「转变」:巡检 6 小时一轮,如果每轮都记,一个失效一周的账号会攒下
28 条一模一样的「CK 检测失效」把时间线淹掉。判定失效是**一次状态转变**,
不是每轮读数。(旧实现注释里写着「避免每轮刷屏」,代码却没做到。)
"""
previous = account.ck_check_status or ""
now = occurred_at or datetime.now(UTC)
if verdict != "error":
account.ck_check_status = verdict
data = detail or {}
account.ck_check_result = {
"fish_ball": data.get("fish_ball"),
"nickname": data.get("nickname"),
"level": data.get("level"),
"message": data.get("message", ""),
"verdict": verdict,
}
account.ck_checked_at = now
if verdict == "invalid" and previous != "invalid":
emit(
db,
account_id=account.id,
event_type=EVENT_CK_INVALID,
title="CK 检测失效",
category=CATEGORY_AUTH,
occurred_at=now,
detail={
"message": data.get("message", ""),
"nickname": data.get("nickname"),
},
source="login",
actor_type=actor_type,
actor_id=actor_id,
)
sync_status(db, account, occurred_at=now, actor_type=actor_type, actor_id=actor_id)
def on_login_success(
db: Session,
account: Account,
@@ -270,6 +328,49 @@ def on_login_success(
sync_status(db, account, occurred_at=occurred_at)
def on_ck_cleared(
db: Session,
account: Account,
*,
occurred_at: datetime | None = None,
actor_id: int | None = None,
reason: str = "manual_clear",
) -> None:
"""清空账号的 CK:**CK 本体与健康状态一起清**,记一条 `ck_cleared`,再重算状态。
⚠️ 这是**不可逆**操作(重登当前成功率极低,清掉很可能补不回来),所以时间线
必须留痕。单个清除与批量清除共用这一条路径 —— 以前是两处各写一遍,
改一处漏一处只是时间问题。
"""
now = occurred_at or datetime.now(UTC)
account.cookie = ""
account.cookie_updated_at = now
account.ck_check_status = ""
account.ck_check_result = None
account.ck_checked_at = None
emit(
db,
account_id=account.id,
event_type=EVENT_CK_CLEARED,
title="CK 被清除",
category=CATEGORY_AUTH,
occurred_at=now,
source="audit",
actor_type="user" if actor_id else "system",
actor_id=actor_id,
detail={"reason": reason},
)
# 没有 CK 就跑不了业务 → 状态回到「未登录」
sync_status(
db,
account,
occurred_at=now,
actor_type="user" if actor_id else "system",
actor_id=actor_id,
)
def status_payload(account: Account) -> dict[str, Any]:
"""给接口用的状态片段(含中文标签,前端不必再维护一份映射)。"""
return {
+57 -1
View File
@@ -4,11 +4,13 @@ from __future__ import annotations
import json
from collections.abc import Mapping
from datetime import datetime
from typing import Any
from sqlalchemy.orm import Session
from ..models import AuditLog, User
from ..models import Account, AuditLog, User
from .account_events_service import CATEGORY_OPS, emit
_SENSITIVE_KEY_PARTS = (
"cookie",
@@ -71,3 +73,57 @@ def record_audit(
)
db.add(entry)
return entry
def record_account_action(
db: Session,
current: User | None,
*,
account: Account,
action: str,
event_type: str,
title: str,
target: str = "",
detail: Mapping[str, Any] | str | None = None,
success: bool = True,
occurred_at: datetime | None = None,
) -> AuditLog:
"""账号相关操作:**审计与账号时间线一次写完**。
为什么合成一个函数:这两件事必须成对出现 ——
只写审计,账号时间线里就缺一条「谁在什么时候改了什么」;
只发事件,合规审计里就缺一条。
分两次调用写,迟早有人漏一边(S4 之前 `assigned` / `tag_changed`
这两个事件类型定义了、前端也渲染了,但**从来没有代码发过**)。
顺带把 `account_id` 落到审计上:审计页可以按账号过滤,且账号被删除后
仍然查得到 —— `audit_logs.account_id` 是普通整型列、**没有外键**,
所以允许指向已删除的账号(这正是删除账号时也记它的原因)。
"""
entry = record_audit(
db,
current,
action=action,
target=target or account.username,
detail=detail,
success=success,
)
entry.account_id = account.id
# 事件是旁路:emit 内部吞异常,记不上也绝不影响业务写入。
emit(
db,
account_id=account.id,
event_type=event_type,
title=title,
category=CATEGORY_OPS,
occurred_at=occurred_at,
# 细节同时给事件一份(先脱敏,避免凭据类字段进时间线)。
# Mapping 直接作为事件 detail,不再套一层 —— 时间线里看到的就是
# {"from": ..., "to": ...},前端不必再往里挖一层。
detail=dict(_safe_value(dict(detail))) if isinstance(detail, Mapping) else None,
source="audit",
actor_type="user",
actor_id=current.id if current else None,
)
return entry
@@ -17,8 +17,7 @@ from sqlalchemy import or_
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 ...account_lifecycle import MANUAL_STATES, on_ck_verdict
from ...cookie_check_service import check_douyu_cookie
from ...douyu_service import cookie_operable_filter
@@ -159,40 +158,9 @@ def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]:
invalid += 1
else:
errors += 1
# error 时保留该账号原来的结论:查不到不等于失效,宁可维持上一次判断。
if verdict != "error":
account.ck_check_status = verdict
account.ck_check_result = {
"fish_ball": result.get("fish_ball"),
"nickname": result.get("nickname"),
"level": result.get("level"),
"message": result.get("message", ""),
"verdict": verdict,
}
account.ck_checked_at = now
# 巡检结论直接决定生命周期状态(正常 ↔ CK 失效),推导交给收口函数。
sync_status(db, account, occurred_at=now)
# 判定失效时记一条账号事件:`ck_check_status` / `ck_checked_at` 都是**覆盖式**
# 字段,覆盖之后就再也看不出「什么时候开始失效的」。只在真的判成 invalid 时记,
# 避免每轮巡检都给同一个账号刷屏。
if verdict == "invalid":
emit(
db,
account_id=account.id,
event_type=EVENT_CK_INVALID,
title="CK 检测失效",
occurred_at=now,
detail={
"message": result.get("message", ""),
"nickname": result.get("nickname"),
},
source="login",
# 幂等键带检测时间:同一账号**再次**失效算新事件,但同一次检测
# 重跑(例如失败重试)不会重复入库。
source_ref=f"ck_invalid:{account.id}:{int(now.timestamp())}",
)
# 结论落定 = 写 ck_check_* + 由好变坏时记事件 + 重算生命周期状态。
# 三件事的**顺序与口径**收在 `on_ck_verdict` 里,四个检测入口共用。
on_ck_verdict(db, account, verdict, detail=result, occurred_at=now)
if index % FLUSH_EVERY == 0:
db.commit()
+9 -9
View File
@@ -10,7 +10,7 @@ from core.douyu import DouyuActivityError
from ..database import SessionLocal
from ..models import DouyuTask
from .account_lifecycle import sync_status
from .account_lifecycle import on_ck_verdict
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 重导出)
@@ -67,14 +67,14 @@ class DouyuBatchRunner(
return
cookie_check = check_douyu_cookie(cookie)
account.ck_check_status = "valid" if cookie_check["valid"] else "invalid"
account.ck_check_result = {
key: cookie_check.get(key)
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"])
# 执行前的这次检测同样要写结论、由好变坏记事件、重算状态。
on_ck_verdict(
worker_db,
account,
"valid" if cookie_check["valid"] else "invalid",
detail=cookie_check,
occurred_at=cookie_check["checked_at"],
)
worker_db.commit()
if not cookie_check["valid"]:
message = f"Cookie 已失效,请重新登录:{cookie_check['message']}"
+9 -11
View File
@@ -22,7 +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 .account_lifecycle import on_ck_verdict, on_login_success
from .cookie_check_service import check_douyu_cookie
from .douyu_service import LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
@@ -466,17 +466,15 @@ class LoginBatchRunner:
message = result.message or "登录成功"
if self.mode == "relogin" and account is not None:
check_result = check_douyu_cookie(result.cookie)
# 复检结论写账号(CK 在哪,健康状态就在哪)。
account.ck_check_status = (
"valid" if check_result["valid"] else "invalid"
# 复检结论写账号(CK 在哪,健康状态就在哪),走统一入口:
# 写结论 + 由好变坏记事件 + 重算状态。
on_ck_verdict(
worker_db,
account,
"valid" if check_result["valid"] else "invalid",
detail=check_result,
occurred_at=check_result["checked_at"],
)
account.ck_check_result = {
"fish_ball": check_result["fish_ball"],
"nickname": check_result["nickname"],
"level": check_result["level"],
"message": check_result["message"],
}
account.ck_checked_at = check_result["checked_at"]
if check_result["valid"]:
message = "重新登录成功,Cookie 有效"
else:
+11 -2
View File
@@ -49,8 +49,17 @@ export const accountApi = {
// 详情页:状态卡 + 统计
overview: (id: number) => api.get<AccountOverview, AccountOverview>(`/accounts/${id}/overview`),
// 时间线(倒序分页),event_type 为空表示全部
events: (id: number, params: { page?: number; page_size?: number; event_type?: string }) =>
events: (
id: number,
params: { page?: number; page_size?: number; event_type?: string; category?: string },
) =>
api.get<AccountEventPage, AccountEventPage>(`/accounts/${id}/events`, {
params: { page: 1, page_size: 50, ...params, event_type: params.event_type || '' },
params: {
page: 1,
page_size: 50,
...params,
event_type: params.event_type || '',
category: params.category || '',
},
}),
};
+6
View File
@@ -92,13 +92,19 @@ export type AccountEventType =
export interface AccountEventItem {
id: number;
event_type: AccountEventType | string;
/** 粗分类:lifecycle / auth / business / ops */
category?: string;
occurred_at: string;
title: string;
/** 有符号:充值为正、兑换消耗为负 */
amount: number | null;
balance_after: number | null;
subject_type?: string;
subject_key?: string;
detail: Record<string, unknown> | null;
source: string;
actor_type?: string;
actor_id?: number | null;
}
export interface AccountEventPage {
+22 -1
View File
@@ -22,8 +22,10 @@ const EVENT_STYLE: Record<string, { label: string; color: string }> = {
imported: { label: '导入', color: 'default' },
login_ok: { label: '登录', color: 'blue' },
ck_invalid: { label: 'CK 失效', color: 'red' },
ck_cleared: { label: 'CK 已清除', color: 'red' },
relogin_ok: { label: '重登成功', color: 'green' },
relogin_failed: { label: '重登转人工', color: 'orange' },
status_changed: { label: '状态变更', color: 'geekblue' },
bind_ok: { label: '绑定', color: 'cyan' },
recharge: { label: '充值', color: 'green' },
exchange: { label: '兑换', color: 'purple' },
@@ -34,6 +36,14 @@ const EVENT_STYLE: Record<string, { label: string; color: string }> = {
tag_changed: { label: '标签', color: 'default' },
};
/** 事件粗分类(与后端 `account_events_service.CATEGORY_*` 对应)。 */
const CATEGORY_OPTIONS = [
{ value: 'lifecycle', label: '状态变迁' },
{ value: 'auth', label: '登录与 CK' },
{ value: 'business', label: '业务流水' },
{ value: 'ops', label: '人工操作' },
];
/** 生命周期状态 → 展示色(与账号列表页保持一致,取值域见后端 account_lifecycle)。 */
const STATUS_TONES: Record<string, string> = {
imported: 'default',
@@ -78,6 +88,7 @@ export default function AccountDetailPage() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [eventType, setEventType] = useState('');
const [category, setCategory] = useState('');
const [eventsLoading, setEventsLoading] = useState(false);
const loadOverview = useCallback(async () => {
@@ -100,6 +111,7 @@ export default function AccountDetailPage() {
page,
page_size: pageSize,
event_type: eventType,
category,
});
setEvents(data.items);
setEventTotal(data.total);
@@ -108,7 +120,7 @@ export default function AccountDetailPage() {
} finally {
setEventsLoading(false);
}
}, [accountId, page, pageSize, eventType]);
}, [accountId, page, pageSize, eventType, category]);
useEffect(() => {
void loadOverview();
@@ -303,6 +315,15 @@ export default function AccountDetailPage() {
title="账号时间线"
extra={
<Space>
<Select
size="small"
allowClear
style={{ width: 120 }}
placeholder="全部分类"
value={category || undefined}
onChange={(v) => { setCategory(v || ''); setPage(1); }}
options={CATEGORY_OPTIONS}
/>
<Select
size="small"
style={{ width: 140 }}