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
+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 }}