Files
live-hub-py/tests/test_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

346 lines
13 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.
"""S5:业务事实直发事件。
取代了原来的「收集器事后扫 `douyu_tasks.result` 反推事件」。本文件守住三件事:
1. **命名构造函数的字段口径**(金额符号、detail、subject);
2. **幂等**:`source_ref=task_ref(task_id)` —— 让直发与仍在跑的收集器能互相去重,
也让「runner 轮询」与「供应商回调」不会对同一笔充值重复记账;
3. **发射顺序**:事件必须在 `_mark_task` **之前**发。`_mark_task` 内部 commit,
而 `emit` 只 RELEASE SAVEPOINT、不提交外层事务,runner 的 `finally` 只 close
(未提交即回滚)—— 发在后面会被静默丢弃,且 `emit` 仍返回 True。
"""
from datetime import UTC, datetime
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AccountEvent, DouyuTask, User
from web.backend.services import account_events_service as svc
from web.backend.services import business_events as biz
NOW = datetime(2026, 10, 4, 12, 0, 0, tzinfo=UTC)
class _Base:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.session.query(AccountEvent).delete()
self.user = User(username="biz-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="biz-acc", password="p", email="b@e.com", email_password="ep"
)
self.session.add(self.account)
self.session.commit()
def teardown_method(self):
self.session.close()
def events(self, event_type: str) -> list[AccountEvent]:
return (
self.session.query(AccountEvent)
.filter(
AccountEvent.account_id == self.account.id,
AccountEvent.event_type == event_type,
)
.order_by(AccountEvent.id.asc())
.all()
)
class TestConstructors(_Base):
def test_recharge_records_amount_and_channel(self):
assert (
biz.record_recharge(
self.session,
self.account,
task_id=101,
amount=100,
channel="wechat_qr",
douyu_uid="u1",
)
is True
)
self.session.commit()
events = self.events(svc.EVENT_RECHARGE)
assert len(events) == 1
event = events[0]
assert event.amount == 100
assert event.category == svc.CATEGORY_BUSINESS
assert event.source_ref == "douyu_task:101"
assert event.detail["channel"] == "wechat_qr"
assert event.detail["confirmed"] is True
def test_recharge_without_amount_still_records(self):
"""金额拿不到时宁可记一条金额为空的事件,也不要凭空编一个数字。"""
biz.record_recharge(self.session, self.account, task_id=102, amount=None)
self.session.commit()
event = self.events(svc.EVENT_RECHARGE)[0]
assert event.amount is None
def test_recharge_marks_supplier_confirmation_honestly(self):
"""供应商终态成功 ≠ 斗鱼余额已变,必须如实标注。"""
biz.record_recharge(
self.session,
self.account,
task_id=103,
amount=50,
channel="supplier_api",
confirmed=False,
)
self.session.commit()
event = self.events(svc.EVENT_RECHARGE)[0]
assert event.detail["confirmed"] is False
assert "供应商回执" in event.title
def test_exchange_amount_is_negative_and_subject_is_commodity(self):
biz.record_exchange(
self.session,
self.account,
task_id=201,
name="兔兔白日梦",
score=1200,
commodity_id="sku-9",
points_after=800,
scope="elite",
)
self.session.commit()
event = self.events(svc.EVENT_EXCHANGE)[0]
assert event.amount == -1200
assert event.balance_after == 800
assert event.subject_type == "commodity"
assert event.subject_key == "sku-9"
assert event.detail["scope"] == "elite"
def test_exchange_without_score_keeps_amount_null(self):
biz.record_exchange(
self.session, self.account, task_id=202, name="x", score=None
)
self.session.commit()
assert self.events(svc.EVENT_EXCHANGE)[0].amount is None
def test_points_credit_marks_unconfirmed(self):
"""未确认到账也记,但必须标出来 —— 不能把「预期」写成「已到账」。"""
biz.record_points_credit(
self.session,
self.account,
task_id=301,
gain=980,
points_after=100,
confirmed=False,
)
self.session.commit()
event = self.events(svc.EVENT_POINTS_CREDIT)[0]
assert event.amount == 980
assert event.detail["confirmed"] is False
assert "未确认" in event.title
def test_points_credit_confirmed(self):
biz.record_points_credit(
self.session, self.account, task_id=302, gain=10, confirmed=True
)
self.session.commit()
event = self.events(svc.EVENT_POINTS_CREDIT)[0]
assert event.detail["confirmed"] is True
assert "未确认" not in event.title
def test_bind_records_scope_and_rebind(self):
biz.record_bind(
self.session,
self.account,
task_id=401,
scope="esports",
role_name="小明",
rebind=True,
)
self.session.commit()
event = self.events(svc.EVENT_BIND_OK)[0]
assert event.subject_type == "esports_role"
assert event.subject_key == "小明"
assert event.detail["rebind"] is True
assert "换绑" in event.title
def test_bind_weak_is_flagged(self):
biz.record_bind(
self.session,
self.account,
task_id=402,
scope="elite",
role_name="小华",
weak=True,
)
self.session.commit()
event = self.events(svc.EVENT_BIND_OK)[0]
assert event.detail["confirmed"] is False
assert "未回查确认" in event.title
class TestIdempotency(_Base):
"""同一个 task 只能对应一条事实 —— 这是直发与收集器/回调共存的基石。"""
def test_same_task_id_does_not_duplicate(self):
biz.record_recharge(self.session, self.account, task_id=500, amount=10)
self.session.commit()
# 模拟「另一个路径也观测到同一笔」(收集器重跑 / 供应商回调)
biz.record_recharge(self.session, self.account, task_id=500, amount=10)
self.session.commit()
assert len(self.events(svc.EVENT_RECHARGE)) == 1
def test_different_task_id_is_a_new_fact(self):
biz.record_recharge(self.session, self.account, task_id=501, amount=10)
self.session.commit()
biz.record_recharge(self.session, self.account, task_id=502, amount=10)
self.session.commit()
assert len(self.events(svc.EVENT_RECHARGE)) == 2
def test_task_ref_matches_old_collector_key(self):
"""必须与旧收集器的 key 完全一致,否则迁移窗口内会重复记账。"""
assert biz.task_ref(123) == "douyu_task:123"
class TestEmitSurvivesWorkerSessionClose(_Base):
"""发射顺序的**行为验证**:事件要能挺过 runner 的 `finally: worker_db.close()`。
runner 的每个任务在 `_execute_one` 的 finally 里只 close 不 commit。
`_mark_task` 内部有 commit,所以「紧贴 _mark_task 之前 emit」会被那次 commit
一起提交;若把 emit 放在 _mark_task 之后,它就落在一个没人提交的事务里,
close 时被回滚 —— 而且 `emit` 照样返回 True,没有任何日志。
"""
def test_event_committed_together_with_mark_task(self):
# 模拟 runner:emit 后由 _mark_task 的 commit 收尾
biz.record_recharge(self.session, self.account, task_id=600, amount=7)
self.session.commit() # 等价于 _mark_task 内部的 commit
self.session.close() # 等价于 runner 的 finally
fresh = make_test_session(self.engine)
try:
rows = (
fresh.query(AccountEvent)
.filter(AccountEvent.source_ref == "douyu_task:600")
.all()
)
assert len(rows) == 1, "事件没有挺过 session close —— 发射顺序错了"
finally:
fresh.close()
def test_emit_without_commit_is_lost_on_close(self):
"""反证:不 commit 就 close,事件确实会丢(这就是顺序规则存在的理由)。"""
biz.record_recharge(self.session, self.account, task_id=601, amount=7)
self.session.close() # 故意不 commit
fresh = make_test_session(self.engine)
try:
rows = (
fresh.query(AccountEvent)
.filter(AccountEvent.source_ref == "douyu_task:601")
.all()
)
assert rows == [], "若不 commit 也能落库,说明事务边界与预期不符"
finally:
fresh.close()
class TestCollectorIsGone:
"""收集器必须被彻底删掉,不留半条命。"""
def test_module_removed(self):
import importlib
try:
importlib.import_module(
"web.backend.services.automation.jobs.account_events_collect"
)
except ModuleNotFoundError:
return
raise AssertionError("收集器模块还在")
def test_no_collector_symbols_left(self):
assert not hasattr(svc, "COLLECTED_TASK_TYPES")
assert not hasattr(svc, "_task_to_event")
def test_job_key_not_registered(self):
"""⚠️ `registered_jobs()` 返回的是 **dict**(key → JobSpec)。
写成 `{spec.key for spec in registered_jobs()}` 会遍历出字符串键、
`spec.key` 直接报错;而如果它恰好是空字典,推导式又会得到空集让断言**假绿**。
所以这里显式 register 后再按键判断。
"""
from web.backend.services.automation import (
register_default_jobs,
registered_jobs,
)
register_default_jobs()
keys = set(registered_jobs())
assert "account_events_collect" not in keys
# 反向确认这个断言不是假绿:新作业确实在里面
assert "douyu_tasks_retention" in keys
class TestEmitOrderingInRunners:
"""源码扫描:runner 里的 `record_*` 必须在 `_mark_task(..., "success")` **之前**。
这条规则很隐蔽(违反了不会报错、只是事件静默消失),所以用用例钉住。
做法:对每个 `record_*` 调用,找它之后出现的第一个 `_mark_task(`,该调用里
必须带 `"success"`;如果事件被写在 mark 之后,那么它后面第一个 `_mark_task`
要么是另一个分支的、要么根本不存在 —— 两种情况都会让用例变红。
"""
RUNNER_FILES = (
"web/backend/services/douyu_runner_goods.py",
"web/backend/services/douyu_runner_gold.py",
"web/backend/services/douyu_runner_bind.py",
"web/backend/services/douyu_runner_donate.py",
"web/backend/services/douyu_runner_xpd.py",
)
def test_record_calls_precede_success_mark(self):
import re
from pathlib import Path
root = Path(__file__).resolve().parents[1]
record_re = re.compile(r"\brecord_(recharge|exchange|points_credit|bind)\(")
offenders: list[str] = []
checked = 0
for rel in self.RUNNER_FILES:
source = (root / rel).read_text(encoding="utf-8")
for match in record_re.finditer(source):
checked += 1
tail = source[match.end() :]
mark = tail.find("_mark_task(")
line_no = source[: match.start()].count("\n") + 1
if mark == -1:
offenders.append(f"{rel}:{line_no} 之后没有 _mark_task")
continue
# 取这次 _mark_task 调用的前若干字符,判断是不是 success 分支
snippet = tail[mark : mark + 400]
if '"success"' not in snippet:
offenders.append(
f"{rel}:{line_no} 之后的第一个 _mark_task 不是 success"
)
assert checked >= 10, f"只扫到 {checked} 个 record_ 调用,扫描规则可能失效了"
assert not offenders, (
"record_* 必须在 _mark_task(success) **之前**调用"
"(否则事件会被 runner 的 close 静默回滚):\n" + "\n".join(offenders)
)
class TestTaskReadyForApi(_Base):
def test_douyu_task_still_works(self):
"""兜底:删掉收集器不应影响 douyu_tasks 本身的读写。"""
task = DouyuTask(
batch_id="b1",
account_id=self.account.id,
task_type="exchange_goods",
status="success",
result={"commodity_name": "x"},
created_by=self.user.id,
)
self.session.add(task)
self.session.commit()
assert task.id is not None