"""账号事件流:写入与查询。 ## 定位 `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