Files
live-hub-py/tests/test_business_events.py
T
yml2213 0b1fc1dee0 feat(events): 补齐资产【出项】—— 开通金额、鱼翅消耗、积分到期(C 最全)
审「哪些操作该进账号生命周期」时发现:**进项有账、出项没账** ——
充值/积分到账都有金额,而开通(花积分)、送礼(花鱼翅)几乎没记,
于是「一共充了多少」算得出来,「花哪儿了」算不出来。

按确认的范围一次做完(应用宝充值**不动** —— 它按游戏角色记账、且未启用)。

## A. 开通类事件补金额

`elite_opened` / `esports_opened` 以前只记「开通后的积分余额」,不记**花了多少**。
现在补 `amount = -(开通前后积分差)`,并用**实测余额差**而不是"开通要多少分"的常量
(活动价、折扣会让实际扣减不同)。任一侧拿不到就不写金额,不编数字。

## B. 鱼翅消耗事件(新 `gold_spent`)

送礼以前只发「积分到账」,**花掉的鱼翅没有任何记录**。现在:
- 送礼**前后各刷一次**鱼翅(原来只在送礼后刷),取**实测余额差**;
- 发 `gold_spent`,`amount` 用负数(与 `exchange` 一致);
- 余额没减少或拿不到 → **不发**:宁可少一条,也不要记一笔没发生的消耗。

## C. 积分到期(新 `points_expire`)

积分到期是资产真实减少,但**斗鱼不会通知** —— 只在积分查询里给 `willExpireTime`。
所以:
- 新增 `accounts.points_expire_at`(迁移 `0046`),送礼流程学到就写下来;
- 新增作业 `points_expire_check`:扫「已到期且积分未归零」的账号,补写事件并清标记
  (不重复记);积分已为 0 则只清标记、不发事件(避免 `-0` 噪声)。

⚠️ **金额的诚实边界**:到期那一刻**到底扣了多少,我们没亲眼看到**。
所以默认 `confirmed=False`、标题带「(预计)」,记的是「当时余额」这个**预计要归零**的数量,
**不假装它是确认过的扣减**。作业刻意不发网络请求(纯 DB 可跑、不需要 CK),
要变成已确认只能由「到期后重查、真看到余额下降」的调用方补。

## 顺带修掉一个会静默丢事件的坑

同一个任务可能产生**两条事件**(送礼:既「积分到账」又「消耗鱼翅」)。
`source_ref` 是唯一键 —— 共用 `task_ref(task_id)` 会让**后写的那条被静默丢弃**。
已让副事件带后缀(`douyu_task:{id}:gold`),主事件保持原形态(与旧收集器一致)。
有用例专门钉住「一个任务两条事件都不丢」。

## 验证

- `TZ=Asia/Shanghai`:**550 passed / 6 skipped**(+18 条)
- `TZ=UTC`(事件 + 迁移 + 绑定)95 passed;ruff 全绿;前端 build 通过、lint 0 errors
- 真实开发库:迁移 `0046` 已应用;作业烟测 → 扫到 1 个到期账号,
  写入「积分到期 -500(预计)」`confirmed=False`,标记被清、重跑不再重复

## 现在的资产流水(斗鱼侧)

| 方向 | 覆盖 |
| --- | --- |
| 进项 | 充值(二维码/供应商/回调三路径去重)✅、积分到账 ✅ |
| 出项 | 兑换 ✅、**开通 ✅(新补金额)**、**送礼鱼翅 ✅(新)**、**积分到期 ✅(新,标预计)** |
| 未动 | 应用宝充值(独立系统、按游戏角色记、当前未启用) |
2026-10-04 18:10:46 +08:00

524 lines
20 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
import pytest
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
class TestAssetOutflow(_Base):
"""资产**出项**也要有金额 —— 否则进项有账、出项没账,对不了账。
这是审「哪些操作需要进账号生命周期」时发现的:充值/积分到账都有金额,
而开通(花积分)、送礼(花鱼翅)几乎没有 —— 能算出「一共充了多少」,
算不出「花哪儿了」。
"""
def test_gold_spent_is_negative(self):
assert (
biz.record_gold_spent(
self.session,
self.account,
task_id=701,
amount=100,
reason="赠送精英令 x1",
source_label="赠送精英令",
balance_after=50,
)
is True
)
self.session.commit()
event = self.events(svc.EVENT_GOLD_SPENT)[0]
assert event.amount == -100 # 出项用负数,和 exchange 一致
assert event.balance_after == 50
assert event.category == svc.CATEGORY_BUSINESS
assert event.source_ref == "douyu_task:701:gold"
def test_gold_spent_skips_non_positive(self):
"""余额没减少(或拿不到)就不发 —— 不要记一笔没发生的消耗。"""
for bad in (None, 0, -5):
assert (
biz.record_gold_spent(
self.session, self.account, task_id=702, amount=bad
)
is False
)
self.session.commit()
assert self.events(svc.EVENT_GOLD_SPENT) == []
def test_points_expire_defaults_to_unconfirmed(self):
"""到期金额默认是**预计**的 —— 我们并没有亲眼看到扣减。"""
biz.record_points_expire(
self.session,
self.account,
amount=500,
expires_at=NOW,
)
self.session.commit()
event = self.events(svc.EVENT_POINTS_EXPIRE)[0]
assert event.amount == -500
assert event.detail["confirmed"] is False
assert "预计" in event.title
def test_points_expire_can_be_confirmed(self):
biz.record_points_expire(self.session, self.account, amount=500, confirmed=True)
self.session.commit()
event = self.events(svc.EVENT_POINTS_EXPIRE)[0]
assert event.detail["confirmed"] is True
assert "预计" not in event.title
def test_two_events_from_one_task_do_not_collide(self):
"""同一个任务既要「积分到账」又要「消耗鱼翅」——
共用 `task_ref` 会撞唯一键、后写的那条被**静默丢弃**。副事件带后缀避免它。
"""
biz.record_points_credit(
self.session, self.account, task_id=703, gain=10, source_label="精英令"
)
biz.record_gold_spent(
self.session,
self.account,
task_id=703,
amount=100,
source_label="赠送精英令",
)
self.session.commit()
assert len(self.events(svc.EVENT_POINTS_CREDIT)) == 1
assert len(self.events(svc.EVENT_GOLD_SPENT)) == 1, (
"副事件被主事件的幂等键挤掉了"
)
class TestPointsExpireJob(_Base):
"""到期补记作业:扫「已到期」的账号,写一条事件并清标记。"""
def _job(self):
from web.backend.models import AutomationJob
return AutomationJob(
key="points_expire_check",
title="积分到期补记",
interval_seconds=86400,
batch_size=200,
concurrency=1,
)
def _run(self):
from web.backend.services.automation.jobs import points_expire
out = points_expire.run_points_expire_check(self.session, self._job())
self.session.commit()
return out
def test_expired_points_are_recorded_once(self):
from datetime import timedelta
self.account.points = 500
self.account.points_expire_at = NOW - timedelta(days=1)
self.session.commit()
out = self._run()
assert out["written"] == 1
events = self.events(svc.EVENT_POINTS_EXPIRE)
assert len(events) == 1
assert events[0].amount == -500
assert events[0].detail["confirmed"] is False
# 标记被清掉 → 再跑一次不会重复记
self.session.refresh(self.account)
assert self.account.points_expire_at is None
assert self._run()["written"] == 0
def test_not_yet_expired_is_left_alone(self):
from datetime import timedelta
self.account.points = 500
self.account.points_expire_at = NOW + timedelta(days=3)
self.session.commit()
assert self._run()["written"] == 0
self.session.refresh(self.account)
assert self.account.points_expire_at is not None, "没到期的标记不该被清"
def test_zero_points_clears_marker_without_event(self):
"""积分已经为 0 就没有东西可到期 —— 写一条 -0 只是噪声。"""
from datetime import timedelta
self.account.points = 0
self.account.points_expire_at = NOW - timedelta(days=1)
self.session.commit()
out = self._run()
assert out["written"] == 0
assert out["skipped_zero"] == 1
assert self.events(svc.EVENT_POINTS_EXPIRE) == []
self.session.refresh(self.account)
assert self.account.points_expire_at is None
class TestExpireAtParsing:
"""斗鱼给的到期时间形态不定(时间戳或字符串),两种都要兜住。"""
@pytest.mark.parametrize(
"value",
[
1791115200, # 秒级时间戳
"1791115200", # 字符串时间戳
1791115200000, # 毫秒时间戳
"2026-10-04 12:00:00", # 常见字符串
"2026-10-04T12:00:00",
],
)
def test_parses_known_shapes(self, value):
from web.backend.services.douyu_runner_donate import _parse_expire_at
parsed = _parse_expire_at(value)
assert parsed is not None
assert parsed.tzinfo is not None, "必须是 aware UTC,否则入库会偏"
@pytest.mark.parametrize("value", [None, "", "不是什么时间", True, {}])
def test_unknown_shapes_return_none(self, value):
from web.backend.services.douyu_runner_donate import _parse_expire_at
assert _parse_expire_at(value) is None, "认不出来就返回 None,不要猜一个时刻"