时间线从此覆盖**全部**业务事实,`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` 旧凭据列。
249 lines
10 KiB
Python
249 lines
10 KiB
Python
"""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}"
|