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:
+25
-16
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 万行),收进去时间线没法看。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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']}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 || '',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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,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 }}
|
||||
|
||||
Reference in New Issue
Block a user