Files
live-hub-py/web/backend/services/business_events.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

252 lines
8.6 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.
"""业务事实的**命名构造**:充值 / 兑换 / 积分到账 / 绑定。
## 为什么单独一个模块
`emit()` 只负责「往时间线写一条」,它不知道「一次兑换」到底该长什么样。
以前这件事由收集器从 `douyu_tasks.result` 的 JSON 里**反推**(猜字段路径),
结果是:
- 新增业务类型没人想起改 allowlist → **静默丢数据**
(电竞兑换、和平小店兑换、锁单支付、电竞礼物积分从来没进过时间线);
- 同一件事的字段口径散在 runner 与收集器两处,容易分叉;
- `success` 不等于「业务真的发生了」时,把假成功也记成事实。
现在反过来:**业务代码在动作成功的那一刻,用这里的构造函数声明事实**。
每类事实的字段、标题、金额符号只定义一次。
## 两条硬规则
1. **必须紧贴 `_mark_task(..., "success", ...)` 之前调用。**
`_mark_task` 内部 `db.commit()`,而 `emit()` 用 `db.begin_nested()` 只 RELEASE
SAVEPOINT、不提交外层事务;`douyu_runner` 的 `finally` 只 `close()`(未提交即回滚)。
在 `_mark_task` **之后**发事件会被静默回滚 —— 而且 `emit()` 返回 True、无任何日志。
放在之前还有个好处:事件与任务状态**同一次提交**,原子。
2. **幂等键用 `task_ref(task.id)`**,与旧收集器的 key 完全一致。
于是迁移窗口内「直发」与「仍在跑的收集器」会自动去重,可以安全并存后再删作业。
⚠️ 不要用秒级时间戳当幂等键:撞唯一键会让 `emit()` 静默吞掉事件
(见 `account_lifecycle` 里那段复盘)。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy.orm import Session
from ..models import Account
from .account_events_service import (
CATEGORY_BUSINESS,
EVENT_BIND_OK,
EVENT_EXCHANGE,
EVENT_POINTS_CREDIT,
EVENT_RECHARGE,
emit,
)
def task_ref(task_id: int) -> str:
"""业务事件的幂等键:一条任务行对应一条事实。
与旧收集器(`f"douyu_task:{row['id']}"`)**完全一致**,所以直发与收集器
同时观测到同一笔业务时只会写一条 —— 这让「先直发、观察一段、再删收集器」
成为安全的迁移路径。
"""
return f"douyu_task:{int(task_id)}"
def record_recharge(
db: Session,
account: Account,
*,
task_id: int,
amount: int | None,
occurred_at: datetime | None = None,
order_id: str = "",
supplier_order_id: str = "",
channel: str = "",
douyu_uid: str = "",
balance_after: int | None = None,
confirmed: bool = True,
) -> bool:
"""充值鱼翅到账。
`amount` 用**本地已知的充值金额**,不要从 `result` 里找 —— 二维码链路的
`result` 里根本没有金额(`create_gold_qr` 只返回 `pay_url/token/raw`),
旧收集器因此在二维码充值上恒写 `amount=None`,导致 `accounts.recharge_total`
系统性少计。
`confirmed=False` 表示只有供应商的终态回执、没有核对斗鱼余额变化。
"""
title = f"充值 {amount}" if amount is not None else "充值到账"
if not confirmed:
title += "(供应商回执)"
return emit(
db,
account_id=account.id,
event_type=EVENT_RECHARGE,
title=title,
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=amount,
balance_after=balance_after,
detail={
"order_id": order_id,
"supplier_order_id": supplier_order_id,
"channel": channel,
"douyu_uid": douyu_uid,
"confirmed": confirmed,
},
source="douyu_task",
subject_type="recharge",
subject_key=order_id or supplier_order_id or "",
source_ref=task_ref(task_id),
)
def record_exchange(
db: Session,
account: Account,
*,
task_id: int,
name: str,
score: int | None,
occurred_at: datetime | None = None,
commodity_id: str = "",
order_id: str = "",
exchange_id: str = "",
game_name: str = "",
game_channel: str = "",
points_after: int | None = None,
scope: str = "elite",
) -> bool:
"""商品/皮肤兑换成功 —— **只在真的消耗了积分并兑换成功时调用**。
注意「预约到货 / 已预兑 / 预兑确认」这三类也把任务标成 `success`,但它们
**不消耗积分、也没兑换到东西**,绝不能走这里(旧收集器只按 `status=="success"`
过滤,于是每预约一次就生成一条金额为空的 `exchange`,把 `exchange_count` 灌大)。
`score` 为负写入 `amount`(消耗),`points_after` 是兑换后的积分余额。
"""
return emit(
db,
account_id=account.id,
event_type=EVENT_EXCHANGE,
title=f"兑换 {name}" if name else "兑换",
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=-int(score) if isinstance(score, (int, float)) else None,
balance_after=points_after,
detail={
"commodity_id": commodity_id,
"commodity_name": name,
"order_id": order_id,
"exchange_id": exchange_id,
"game_name": game_name,
"game_channel": game_channel,
"score": score,
"scope": scope,
},
source="douyu_task",
subject_type="commodity",
subject_key=commodity_id or name or "",
source_ref=task_ref(task_id),
)
def record_points_credit(
db: Session,
account: Account,
*,
task_id: int,
gain: int | None,
occurred_at: datetime | None = None,
points_after: int | None = None,
expire_at: Any = None,
confirmed: bool = True,
source_label: str = "精英令",
) -> bool:
"""积分到账(送礼/任务发放)。
`confirmed` 必须如实反映「有没有真的看到积分涨上去」。送礼任务在**没确认**
到账时也会把任务标成 `success`(消息里加一句「(未确认涨到 X)」),旧收集器
照样把 `gift_points_expected_gain` 当成事实写进台账 —— 把「预期」记成了「已到账」。
这里保留事件(延迟到账也是真实发生的发放),但**把 `confirmed` 明确落进
detail**:宁可标清楚是未确认,也不假装它是确认过的数字。
"""
title = f"积分到账 +{gain}" if gain is not None else "积分到账"
title = f"{source_label}{title}"
if not confirmed:
title += "(未确认)"
return emit(
db,
account_id=account.id,
event_type=EVENT_POINTS_CREDIT,
title=title,
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
amount=int(gain) if isinstance(gain, (int, float)) else None,
balance_after=points_after,
detail={
"confirmed": confirmed,
"expire_at": expire_at,
"source": source_label,
},
source="douyu_task",
subject_type="points",
source_ref=task_ref(task_id),
)
def record_bind(
db: Session,
account: Account,
*,
task_id: int,
scope: str,
role_name: str = "",
occurred_at: datetime | None = None,
act_alias: str = "",
rebind: bool = False,
weak: bool = False,
) -> bool:
"""角色绑定 / 换绑**真的发生了**。
⚠️ 只在「确认接口成功」时调用。`confirm_bind` 里还有一条分支是
「检测到账号**本来就已经绑定**」——它什么都没做也把任务标成 `success`,
旧收集器把每次重复点「确认绑定」都记成一条 `bind_ok`。
`weak=True` 表示确认接口成功、但回查拿不到结果(没能亲眼确认生效),
仍然记,但标出来。
"""
label = "换绑" if rebind else "绑定"
scope_label = {"elite": "精英宝典", "esports": "电竞手册", "peace": "和平小店"}.get(
scope, scope
)
title = f"{scope_label}{label}"
if role_name:
title += f":{role_name}"
if weak:
title += "(未回查确认)"
return emit(
db,
account_id=account.id,
event_type=EVENT_BIND_OK,
title=title,
category=CATEGORY_BUSINESS,
occurred_at=occurred_at,
detail={
"scope": scope,
"role_name": role_name,
"act_alias": act_alias,
"rebind": rebind,
"confirmed": not weak,
},
source="douyu_task",
subject_type=f"{scope}_role",
subject_key=role_name or "",
source_ref=task_ref(task_id),
)