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

249 lines
10 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.
"""S5b:`douyu_tasks` 保留策略。
守住的核心不是「删得掉」,而是**删得对**:
- 噪声查询按短窗口删;
- **业务事实与前端状态派生依赖的查询按长窗口删**(删早了工作台账号行会变「未查」);
- **未知类型默认长保留**(新增业务类型不该因为「忘了加进某个列表」而被误删);
- **在途任务绝不删**(批次线程正拿着那一行)。
"""
from datetime import UTC, datetime, timedelta
from sqlalchemy import func
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AutomationJob, DouyuTask, User
from web.backend.services.automation.jobs import douyu_tasks_retention as retention
NOW = datetime.now(UTC)
class _Base:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.session.query(DouyuTask).delete()
self.session.query(AutomationJob).filter(
AutomationJob.key == retention.JOB_KEY
).delete()
self.session.commit()
self.user = User(username="ret-admin", password_hash="h", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="ret-acc", password="p", email="r@e.com", email_password="ep"
)
self.session.add(self.account)
self.session.commit()
# 记下 id:`run()` 会 expunge_all(),之后不能再碰 self.account 的属性
# (commit 会 expire 属性,detach 后再访问就是 DetachedInstanceError)。
self.account_id = self.account.id
self.user_id = self.user.id
def teardown_method(self):
self.session.close()
def make_task(
self,
task_type: str,
*,
age_days: float,
status: str = "success",
result: dict | None = None,
) -> DouyuTask:
task = DouyuTask(
batch_id="ret-batch",
account_id=self.account_id,
task_type=task_type,
status=status,
message="m",
result=result if result is not None else {"payload": {}},
created_by=self.user_id,
created_at=NOW - timedelta(days=age_days),
)
self.session.add(task)
self.session.commit()
return task
def job(self, **config) -> AutomationJob:
"""取(或建)作业行。
幂等:`automation_jobs` 的主键就是 `key`,一个用例里跑两次不能插两行。
"""
job = self.session.get(AutomationJob, retention.JOB_KEY)
if job is None:
job = AutomationJob(
key=retention.JOB_KEY,
title="任务日志清理",
enabled=True,
interval_seconds=24 * 3600,
batch_size=100,
concurrency=1,
config=config or None,
updated_at=NOW,
)
self.session.add(job)
job.config = config or None
job.batch_size = 100
self.session.commit()
return job
def run(self, **config) -> dict:
out = retention.run_douyu_tasks_retention(self.session, self.job(**config))
# 作业用**裸 SQL** 删行,ORM 的 identity map 不知道行没了;
# 不 expunge 的话后面访问这些对象会抛 ObjectDeletedError(而不是干净地查不到)。
self.session.expunge_all()
return out
def remaining(self) -> set[tuple[str, int]]:
return {(t.task_type, t.id) for t in self.session.query(DouyuTask).all()}
def count(self) -> int:
return int(self.session.query(func.count(DouyuTask.id)).scalar() or 0)
class TestRetention(_Base):
def test_noise_query_deleted_after_short_window(self):
self.make_task("query_limited_goods", age_days=10)
self.make_task("query_change_bind_time", age_days=10)
out = self.run(noise_days=7, business_days=90)
assert out["deleted"] == 2
assert out["deleted_noise"] == 2
assert self.remaining() == set()
def test_noise_query_kept_within_short_window(self):
self.make_task("query_limited_goods", age_days=3)
out = self.run(noise_days=7, business_days=90)
assert out["deleted"] == 0
assert self.count() == 1
def test_business_fact_kept_past_short_window(self):
"""业务事实不该被 7 天窗口带走 —— 这是分档的全部意义。"""
for task_type in (
"exchange_goods",
"create_gold_qr",
"confirm_bind",
"exchange_xpd_goods",
):
self.make_task(task_type, age_days=30)
out = self.run(noise_days=7, business_days=90)
assert out["deleted"] == 0
assert self.count() == 4
def test_business_fact_deleted_after_long_window(self):
self.make_task("exchange_goods", age_days=100)
out = self.run(noise_days=7, business_days=90)
assert out["deleted"] == 1
assert out["deleted_rest"] == 1
def test_state_derivation_queries_kept_longer(self):
"""工作台从这些查询的 result 反推账号当前状态,删早了显示会变「未查」。"""
for task_type in (
"query_xpd_bind_info",
"query_xpd_role",
"query_esports_game_name",
"query_game_name",
"prepare_esports_bind",
"get_esports_bind_qr",
):
self.make_task(task_type, age_days=30)
out = self.run(noise_days=7, business_days=90)
assert out["deleted"] == 0, f"状态派生依赖的任务被误删:{self.remaining()}"
def test_unknown_task_type_defaults_to_long_retention(self):
"""新增的任务类型默认长保留 —— 绝不能因为「没列进名单」而被删掉。"""
self.make_task("some_brand_new_task_type", age_days=30)
out = self.run(noise_days=7, business_days=90)
assert out["deleted"] == 0
assert self.count() == 1
# 但只要过了长窗口就该被清掉(否则未知类型会永久堆积)
self.make_task("another_new_type", age_days=200)
out = self.run(noise_days=7, business_days=90)
assert out["deleted"] == 1
def test_in_flight_tasks_never_deleted(self):
"""批次线程可能正拿着这些行,无论多老都不能删。"""
for status in ("planned", "pending", "running"):
self.make_task("query_limited_goods", age_days=999, status=status)
self.make_task("query_limited_goods", age_days=999, status="success")
out = self.run(noise_days=7, business_days=90)
assert out["deleted"] == 1
statuses = {t.status for t in self.session.query(DouyuTask).all()}
assert statuses == {"planned", "pending", "running"}
def test_config_windows_are_honoured(self):
self.make_task("query_limited_goods", age_days=2)
out = self.run(noise_days=1, business_days=90)
assert out["deleted"] == 1
assert out["noise_days"] == 1
def test_invalid_config_falls_back_to_defaults(self):
out = self.run(noise_days=0, business_days="abc")
assert out["noise_days"] == retention.DEFAULT_NOISE_DAYS
assert out["business_days"] == retention.DEFAULT_BUSINESS_DAYS
def test_result_payload_is_dropped_with_the_row(self):
"""删整行(而不是只清 result)—— 避免「有行但读不出状态」的隐蔽坏状态。"""
task = self.make_task(
"query_limited_goods",
age_days=30,
result={"limited_goods": [{"a": 1}] * 100},
)
task_id = task.id # 先取:run() 会 expunge_all,之后对象就 detached 了
self.run(noise_days=7, business_days=90)
assert self.session.get(DouyuTask, task_id) is None
def test_batch_budget_is_reported_not_overclaimed(self):
"""预算用尽只报「预算用光」,不谎称「仍有积压」。
预算在上一档用光时,下一档根本没轮到执行 —— 这时无法区分「没得删」和
「没轮到」,所以只给提示。
"""
for _ in range(5):
self.make_task("query_limited_goods", age_days=30)
out = self.run(noise_days=7, business_days=90, max_batches=1)
# batch_size=100(job 默认),5 条一轮删完(不足一批就 break),预算正好用光
assert out["batches"] == 1
assert out["deleted"] == 5
assert out["batch_budget_exhausted"] is True
def test_budget_not_exhausted_when_work_runs_out(self):
self.make_task("query_limited_goods", age_days=30)
out = self.run(noise_days=7, business_days=90, max_batches=50)
assert out["batch_budget_exhausted"] is False
class TestClassification(_Base):
def test_noise_list_has_no_duplicates(self):
assert len(retention.NOISE_TASK_TYPES) == len(set(retention.NOISE_TASK_TYPES))
def test_noise_list_has_no_business_types(self):
business = {
"exchange_goods",
"exchange_esports_goods",
"exchange_xpd_goods",
"pay_locked_order",
"lock_goods",
"create_gold_qr",
"donate_elite_gift",
"donate_esports_chicken_gift",
"donate_esports_firework_gift",
"confirm_bind",
"confirm_esports_bind",
"confirm_xpd_bind",
"create_elite_qr",
"create_esports_qr",
}
overlap = business & set(retention.NOISE_TASK_TYPES)
assert not overlap, f"业务类型被当成噪声了:{overlap}"
def test_noise_types_exist_in_supported_types(self):
"""噪声名单里的类型必须都是真实存在的任务类型(防拼写错误导致永不生效)。"""
from web.backend.services.douyu_service import SUPPORTED_DOUYU_TASK_TYPES
unknown = set(retention.NOISE_TASK_TYPES) - set(SUPPORTED_DOUYU_TASK_TYPES)
assert not unknown, f"噪声名单里有不存在的任务类型:{unknown}"