Files
live-hub-py/web/backend/services/account_events_service.py
T
yml2213 6e729e7a80 feat(account): S5 业务事实直发事件 + 删掉收集器 + 任务日志保留策略
时间线从此覆盖**全部**业务事实,`douyu_tasks` 也有了可控的增长边界。

## 一、删掉收集器(它必然漏数据)

`jobs/account_events_collect.py` 连同 `_task_to_event` / `COLLECTED_TASK_TYPES` /
水位线一起删除,迁移 `20261004_0043` 清掉它的作业行。删除理由是实测出来的:

- **靠 allowlist 认业务类型,新增类型就静默丢**:原有名单只有 5 个,
  而 `exchange_esports_goods`(电竞兑换)、`exchange_xpd_goods`(和平小店兑换)、
  `pay_locked_order`(锁单支付)、`donate_esports_*`(电竞礼物积分)**从来没进过时间线**;
- **「任务 success」≠「业务真的发生了」**(见下);
- **字段路径靠抽样猜**:二维码充值的 `result` 里根本没有金额,收集器恒写
  `amount=None`,`recharge_total` 系统性少计;
- 作业默认 `enabled=False`,生产上一直没跑。

历史**不回填**(既有结论不变):需要历史走斗鱼侧权威接口
(`query_exchange_records` / `query_xpd_purchase_records` / `query_points` 都是现成任务类型)。

## 二、业务事实直发:命名构造函数

新增 `services/business_events.py`:`record_recharge` / `record_exchange` /
`record_points_credit` / `record_bind`。每类事实的字段、标题、金额符号只定义一次
(与 S4 的 `record_account_action`、`on_ck_verdict` 同一套路)。

**9 处成功点接线**:`exchange_goods` 真兑换分支、`exchange_esports_goods`、
`exchange_xpd_goods`、`pay_locked_order`、`create_gold_qr`(二维码 + 供应商轮询 +
**供应商回调**三条路径)、`donate_elite_gift`、`donate_esports_gift`、
`confirm_bind`、`confirm_esports_bind`。

**只记真实事实**(按你的决定):`exchange_goods` 里「预约到货 / 已预兑 / 预兑确认」
三条 success 分支**不发事件** —— 它们不消耗积分也没换到东西,旧收集器只按 status
收集,于是每预约一次就生成一条金额为空的 `exchange` 把 `exchange_count` 灌大。
`confirm_bind` 里「检测到账号本来就已绑定」那条也跳过。

## 三、修掉两个真 bug(都是钱账)

1. **到账判据 `!=` → `>`**(`douyu_runner_gold.py`):余额**下降**也算「鱼翅已到账」。
   批内并发(最高 10)时同账号送礼会消耗鱼翅,余额一变就被充值任务读成到账,
   **往台账记一笔根本没发生的充值**。
2. **供应商回调晚于 runner 超时**:原来守卫是「任务状态没被改过才更新」,
   于是 runner 先判 failed、回调随后说成功的那笔真金白银**永远不进时间线**。
   改成按**回调自己的终态**判断,并补发事件。

## 四、发射顺序:一个会静默丢事件的坑

`_mark_task` 内部 `db.commit()`,而 `emit` 用 `db.begin_nested()` **只 RELEASE
SAVEPOINT、不提交外层事务**;runner 的 `finally` 只 `close()`(未提交即回滚)。
所以**在 `_mark_task` 之后发事件会被静默丢弃,且 `emit` 仍返回 True**。
所有接线一律紧贴 `_mark_task(success)` **之前**,并加了源码扫描用例钉住
(已反向验证:故意挪到之后,用例立刻报出 file:line)。

## 五、`douyu_tasks` 保留策略(新增作业,默认关闭)

`jobs/douyu_tasks_retention.py`,两档:

| 档 | 保留 |
| --- | --- |
| 噪声查询(只读看一眼就走,且前端不拿它派生账号状态) | **7 天** |
| 其余一切(业务事实 + 状态派生依赖的查询 + **以后新增的类型**) | **90 天** |

长保留档用 `task_type NOT IN (噪声名单)` 表达 → **新增类型默认长保留**,不会因为
「忘了加名单」被误删(正是收集器翻车的反面)。在途任务(planned/pending/running)
**绝不删**;分批删避免长锁;两个窗口都能在作业 `config` 里改,不必发版。

⚠️ 「前端从任务列表反推账号当前状态」那几类查询(`query_xpd_bind_info` /
`query_esports_game_name` 等)**故意留在长保留档** —— 删早了工作台账号行会变
「未查」,那是把**当前显示**搞坏。根治要靠 §3.6 的状态列拆解(后续阶段)。
作业默认 `enabled=False`,由运维决定何时打开。

## 验证

- `TZ=Asia/Shanghai`:**450 passed / 6 skipped**(S4 后 426 → 净增 24)
- `TZ=UTC`(相关文件)101 passed;ruff check/format 全绿
- 前端 build 通过、lint 0 errors
- 真实开发库:迁移已应用(head `20261004_0043`,孤儿作业行已清);
  保留作业实跑确认「作业行默认关闭、SQL 在 MySQL 上正确、近期行不误删」
- **两处反向验证**:把 `record_bind` 挪到 `_mark_task` 之后 → 顺序用例报红;
  修掉 `registered_jobs()` 那个**假绿用例**(它返回 dict,原来的集合推导式
  遍历空字典恒成立),并在断言里加了反向确认

未做(后续):S6 账号页重构 + `bind_status` 三列拆解(做完才能把状态派生类查询
也纳入短保留)、S7 账号检测改账号维度、S8 丢 `login_tasks` 旧凭据列。
2026-10-04 16:00:42 +08:00

240 lines
9.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`)+ 时间线(读本表)。
## 唯一写入路径:直接发射
业务事实**在它发生的那一刻**由业务代码发射(`emit` 或 `business_events` 里的命名
构造函数)。曾经有过第二条路径 —— 一个作业事后扫 `douyu_tasks.result` 的 JSON、
猜字段路径反推事件。那条路已经删掉,原因是它必然漏数据:
- 靠 allowlist 认业务类型 → 新增类型没人想起维护,**静默丢**(电竞兑换、和平小店
兑换、锁单支付、电竞礼物积分因此从来没进过时间线);
- 「任务 success」不等于「业务真的发生了」(预约、已绑定、未确认到账都会被标 success);
- 作业默认关闭,生产上它一直没跑。
历史**不从 `douyu_tasks` 回填**。需要历史时按斗鱼侧权威接口同步
(`query_exchange_records` / `query_xpd_purchase_records` / `query_points` 都是现成任务类型)
—— 宁可少,不可假。
## 两条硬约束
- **发射是旁路**:`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_CK_CLEARED = "ck_cleared" # 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" # 标签变更
EVENT_REMARK_CHANGED = "remark_changed" # 备注变更
EVENT_STATUS_CHANGED = "status_changed" # 生命周期状态变迁(停用/归档/失效…)
# 有符号金额的事件类型(用于页面「累计充值/累计消耗」):
# 充值为正、兑换为负,其余不计入金额统计。
INCOME_TYPES = (EVENT_RECHARGE,)
OUTCOME_TYPES = (EVENT_EXCHANGE,)
# 粗分类:决定时间线怎么分组、以及哪些事件算「业务台账」。
CATEGORY_LIFECYCLE = "lifecycle" # 生命周期状态变迁
CATEGORY_AUTH = "auth" # 登录与 CK
CATEGORY_BUSINESS = "business" # 充值 / 兑换 / 积分 / 开通
CATEGORY_OPS = "ops" # 人工操作(分配 / 标签 / 停用)
def to_naive_utc(value: datetime) -> datetime:
"""把时间归一成 **naive UTC**(本服务所有时间列的落库形态)。
约定与 `database.UTCDateTime` 一致,也是全仓通行写法:
- **naive → 视为「已经是 UTC」,原样使用**;
- aware → 换算到 UTC 后去掉 tzinfo。
⚠️ 绝对不能直接写 `value.astimezone(UTC)`:对 naive 值,Python 会把它当作
**服务器本地时间**再换算。本机 UTC+8 时事件时间会被整整扣掉 8 小时,
而 naive 值的来源很常见 —— 用**裸 SQL** 读 DATETIME 列(如收集器读
`douyu_tasks.finished_at`)拿到的就是 naive,因为它绕过了 `UTCDateTime`
装饰器。仓库里 `scheduler._as_utc` / `yyb_service._as_utc` / `audit.py`
都遵守了这条约定,本函数与它们同口径。
"""
if value.tzinfo is None:
return value
return value.astimezone(UTC).replace(tzinfo=None)
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,
*,
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,
) -> bool:
"""记录一条账号事件。返回是否真的写入(幂等命中/异常时为 False)。
**绝不抛异常**:事件是旁路,不能因为记不上一条时间线就让登录或兑换失败。
用 SAVEPOINT 包住,失败只回滚这一条,不影响调用方已经做过的事。
`occurred_at` 是**业务发生时间**,可以是 aware,也可以是 naive
(naive 按约定视为已是 UTC,见 `to_naive_utc`)。
`category` / `subject_*` / `actor_*` 支撑台账检索:
例如「该账号该商品最近一次兑换」走
`(account_id, event_type, subject_key, occurred_at)` 复合索引。
"""
if not account_id or not event_type:
return False
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,
)
try:
# SAVEPOINT:即使这条插入失败(例如 source_ref 撞唯一键),
# 也只回滚它自己,调用方事务里已完成的写入不受影响。
with db.begin_nested():
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