Files
live-hub-py/web/backend/services/account_events_service.py
T
yml2213 834bfdd1d0 feat(account-events): 账号详情页与账号事件历史全链路支持
1. 新增账号事件数据表 AccountEvent 与迁移版本 20261002_0040_account_events,支持审计 Cookie 变更、自动重登、健康检查及任务执行事件;
2. 后端新增 account_events_service 并扩展 /api/accounts 路由,支持事件查询、过滤与上报;
3. 前端新增 AccountDetailPage 账号详情页组件,支持账号元信息展示、事件历史瀑布流与快捷重登操作;
4. 修复 CK 重登与自动化定时任务中事件关联逻辑,补充 test_account_events 单元测试;
5. 新增生产服务器高负载排查分析报告及相关设计文档。
2026-10-03 12:58:58 +08:00

215 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""账号事件流:写入与查询。
## 定位
`accounts` 存**当前状态**(积分、余额、开通标记),这张表存**发生过什么**。
账号详情页 = 状态卡(读 `accounts`)+ 时间线(读本表)。
## 两条写入路径
1. **直接发射**(`emit`):CK 失效/登录成功/重登、精英宝典与电竞手册开通 —— 这些
只在内存里出现过,不记就永远没有;
2. **增量收集**(`automation/jobs/account_events_collect.py`):充值/兑换/积分到账/绑定 ——
这些已经是 `douyu_tasks` 的行,由收集器按水位线往后提取,不动 runner 代码。
本模块只提供 `_task_to_event` 映射给它用。
## 两条硬约束
- **发射是旁路**:`emit` 任何失败都不能影响登录/兑换主流程(内部吞异常 + 记日志,
并用 SAVEPOINT 隔离,避免污染调用方的事务);
- **幂等**:`source_ref` 唯一键兜住「收集器重跑」「事件重复发射」。没有它,重跑一次
时间线就多一份重复记录。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from loguru import logger
from sqlalchemy import text
from sqlalchemy.orm import Session
# ── 事件类型 ──────────────────────────────────────────────
# 命名保持「名词_状态」风格,便于前端归类与筛选。
EVENT_IMPORTED = "imported" # 账号导入(读 accounts.created_at,一般不落表)
EVENT_LOGIN_OK = "login_ok" # 登录成功(含新 CK)
EVENT_CK_INVALID = "ck_invalid" # CK 判定失效
EVENT_RELOGIN_OK = "relogin_ok" # 自动重登成功
EVENT_RELOGIN_FAILED = "relogin_failed" # 自动重登失败(转人工/退避)
EVENT_BIND_OK = "bind_ok" # 绑定/换绑
EVENT_RECHARGE = "recharge" # 充值到账
EVENT_EXCHANGE = "exchange" # 兑换(含消耗积分)
EVENT_POINTS_CREDIT = "points_credit" # 积分到账(含到期时间)
EVENT_ELITE_OPENED = "elite_opened" # 精英宝典开通
EVENT_ESPORTS_OPENED = "esports_opened" # 电竞手册开通
EVENT_ASSIGNED = "assigned" # 分配客服
EVENT_TAG_CHANGED = "tag_changed" # 标签变更
# 有符号金额的事件类型(用于页面「累计充值/累计消耗」):
# 充值为正、兑换为负,其余不计入金额统计。
INCOME_TYPES = (EVENT_RECHARGE,)
OUTCOME_TYPES = (EVENT_EXCHANGE,)
def _now() -> datetime:
return datetime.now(UTC)
def emit(
db: Session,
*,
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,
) -> bool:
"""记录一条账号事件。返回是否真的写入(幂等命中/异常时为 False)。
**绝不抛异常**:事件是旁路,不能因为记不上一条时间线就让登录或兑换失败。
用 SAVEPOINT 包住,失败只回滚这一条,不影响调用方已经做过的事。
"""
if not account_id or not event_type:
return False
sql = text(
"""
INSERT INTO account_events (
account_id, event_type, occurred_at, title,
amount, balance_after, detail, source, source_ref, created_at
) VALUES (
:account_id, :event_type, :occurred_at, :title,
:amount, :balance_after, :detail, :source, :source_ref, :created_at
)
"""
)
params = {
"account_id": account_id,
"event_type": event_type,
"occurred_at": (occurred_at or _now()).astimezone(UTC).replace(tzinfo=None),
"title": (title or "")[:256],
"amount": amount,
"balance_after": balance_after,
"detail": json.dumps(detail, ensure_ascii=False, default=str)
if detail
else None,
"source": source[:24],
"source_ref": (source_ref or None),
"created_at": _now().replace(tzinfo=None),
}
try:
# SAVEPOINT:即使这条插入失败(例如 source_ref 撞唯一键),
# 也只回滚它自己,调用方事务里已完成的写入不受影响。
with db.begin_nested():
db.execute(sql, params)
return True
except Exception as exc: # noqa: BLE001 - 旁路,任何失败都不上抛
logger.warning(f"写入账号事件失败(account={account_id} {event_type}):{exc}")
return False
# ── douyu_tasks 增量收集 ──────────────────────────────────
# 只收集「业务事件」,例行轮询(query_*)一律不进时间线 —— 它们在 douyu_tasks 里
# 占绝大多数(query_limited_goods 就 6.7 万行),收进去时间线没法看。
COLLECTED_TASK_TYPES = (
"exchange_goods",
"create_gold_qr",
"donate_elite_gift",
"confirm_bind",
"confirm_esports_bind",
)
def _task_to_event(task_type: str, result: Any) -> dict[str, Any] | None:
"""把一条 douyu_tasks 行转成事件参数;解析不出返回 None(跳过并告警)。
⚠️ 字段路径是**抽样实测**得到的(见设计文档 §4.2),不是猜的。
解析不出时必须跳过而不是抛出去 —— 一行坏数据不能卡住整条水位线。
"""
if isinstance(result, str):
try:
result = json.loads(result)
except ValueError:
return None
if not isinstance(result, dict):
return None
if task_type == "exchange_goods":
name = result.get("commodity_name") or "兑换"
score = result.get("score")
points_after = (result.get("points_query") or {}).get("points")
return {
"event_type": EVENT_EXCHANGE,
"title": f"兑换 {name}",
"amount": -int(score) if isinstance(score, (int, float)) else None,
"balance_after": points_after if isinstance(points_after, int) else None,
"detail": {
"commodity_name": name,
"commodity_id": result.get("commodity_id"),
"order_id": result.get("order_id"),
"exchange_id": result.get("exchange_id"),
"game_name": result.get("game_name"),
"game_channel": result.get("game_channel"),
"score": score,
},
}
if task_type == "create_gold_qr":
amount = result.get("buy_num") or result.get("pay_amount")
supplier = result.get("supplier_order") or {}
try:
amount_int = int(float(amount)) if amount is not None else None
except (TypeError, ValueError):
amount_int = None
return {
"event_type": EVENT_RECHARGE,
"title": f"充值 {amount_int if amount_int is not None else ''}".strip(),
"amount": amount_int,
"balance_after": None,
"detail": {
"order_id": result.get("order_id"),
"supplier_order_id": supplier.get("order_id"),
"supplier_finished_at": supplier.get("finish_time"),
"channel": result.get("recharge_channel"),
"douyu_uid": result.get("douyu_uid"),
},
}
if task_type == "donate_elite_gift":
gain = result.get("gift_points_expected_gain")
points = result.get("points")
will_expire = ((result.get("points_query") or {}).get("raw") or {}).get("data")
expire_at = (
will_expire.get("willExpireTime") if isinstance(will_expire, dict) else None
)
return {
"event_type": EVENT_POINTS_CREDIT,
"title": f"积分到账 +{gain}" if gain is not None else "积分到账",
"amount": int(gain) if isinstance(gain, (int, float)) else None,
"balance_after": int(points) if isinstance(points, (int, float)) else None,
"detail": {
"baseline": result.get("gift_points_baseline"),
"target": result.get("gift_points_target"),
"points_expire_at": expire_at,
"gold_balance": result.get("gold_balance"),
},
}
if task_type in ("confirm_bind", "confirm_esports_bind"):
return {
"event_type": EVENT_BIND_OK,
"title": "绑定角色" if task_type == "confirm_bind" else "电竞手册绑定",
"amount": None,
"balance_after": None,
"detail": {"raw_keys": sorted(result.keys())[:20]},
}
return None