时间线从此覆盖**全部**业务事实,`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` 旧凭据列。
252 lines
8.6 KiB
Python
252 lines
8.6 KiB
Python
"""业务事实的**命名构造**:充值 / 兑换 / 积分到账 / 绑定。
|
||
|
||
## 为什么单独一个模块
|
||
|
||
`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),
|
||
)
|