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