Files
live-hub-py/tests/test_account_overview_refresh_job.py
T
yml2213 4c1aff47da feat(douyu): 高价值套装改用官方 storehome 三态判定
高价值套装的准入判定改为只认官方精英宝典 storehome 里账号级的
status:0=可兑换、1=已达兑换上限,商品缺失/接口异常=unknown。
兑换记录和积分记录只作历史展示,不参与是否拥有的判定。

原来的 high_value_owned(JSON 名单)无法区分「确认可兑换」和
「官方接口没返回商品」,也无法表达部分失败。改为两套独立状态 +
检查时间(迁移 20261106_0065),旧快照保守回填:status 缺失的
一律 unknown,不猜。

CK 判定失效时在数据层作废结论(降为 unknown),不是展示层降级。
官方 status 是账号维度的,但 CK 失效那段时间账号可能被别人手工
登录并兑换(这些号共享给客服),缓存里的「可兑换」就不成立了;
只在展示层降级的话,CK 恢复后那条陈旧的「可换」会重新变绿。
error(查不到)不作废 —— 查不到不等于账号出问题了。

高价值结论有自己的时钟:资料刷新除了看 overview_queried_at,还看
high_value_checked_at。两者会脱钩(商品接口单独失败时资料时间被刷新、
高价值时间停在原地),少了这一条这类账号会永久停在旧结论。作废的
结论按 6 小时慢速重试,但「从来没查到过」不因此重选 —— 那是接口对
该账号稳定失败,每轮都重选只会反复撞墙。

商城商品列表改用精英宝典的 manualID 拉取,不再寄生在电竞手册分支里
(否则没配电竞手册的配置就永远拉不到商城,三态全部停在待确认)。

筛选拆成三类「查不到」:unqueried(未查资料,旧口径不变)、
high_value_never(从未读到商城)、high_value_unknown(查过但结论
未知/过期)。之前第二类无处可寻 —— 它既不满足 unqueried 也不满足
high_value_unknown。
2026-11-06 12:25:08 +08:00

350 lines
15 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.
"""定时补全账号资料的作业(`account_overview_refresh`)。
**为什么不能一次性跑全量**:生产约 8,000 个待查账号,一次性发起 ≈ 8 万次斗鱼请求,
很容易触发风控 —— 而风控的代价是所有账号一起受影响。所以做成定时分批,
并复用「自动巡检」的两道保护(`cookie_operable_filter` + 错误率熔断)。
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AutomationJob, User
from web.backend.services.automation.jobs import account_overview_refresh as job
class _Base:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.admin = User(username="ovr-admin", password_hash="h", role="super_admin")
self.session.add(self.admin)
self.session.flush()
self.job = AutomationJob(
key=job.JOB_KEY,
title="账号资料定时补全",
# 「同一账号多久重检」= 7 天(就是界面上那个字段)
interval_seconds=7 * 24 * 3600,
batch_size=10,
concurrency=3,
enabled=False,
)
self.session.add(self.job)
self.session.commit()
def teardown_method(self):
self.session.close()
def account(
self,
name: str,
*,
ck: str = "valid",
queried_days_ago: int | None = None,
auto_managed: bool = True,
status: str = "active",
hv_checked_days_ago: float | None = None,
hv_status: str | None = None,
) -> Account:
acc = Account(
username=name,
password="p",
email=f"{name}@e.com",
email_password="ep",
cookie="acf_uid=1; acf_ccn=c",
ck_check_status=ck,
auto_managed=auto_managed,
status=status,
)
if queried_days_ago is not None:
acc.overview_queried_at = datetime.now(UTC) - timedelta(
days=queried_days_ago
)
if hv_checked_days_ago is not None:
acc.high_value_checked_at = datetime.now(UTC) - timedelta(
days=hv_checked_days_ago
)
if hv_status is not None:
acc.high_value_bairimeng_status = hv_status
acc.high_value_mengtu_status = hv_status
self.session.add(acc)
self.session.commit()
return acc
def selected(self) -> list[Account]:
return job.build_select_query(self.session, self.job, datetime.now(UTC)).all()
class TestSelection(_Base):
def test_only_valid_ck_is_selected(self):
"""只查有效 CK —— 失效的查了也是白查,只会多一条失败记录。"""
self.account("ovr-valid", ck="valid")
self.account("ovr-invalid", ck="invalid")
self.account("ovr-error", ck="error")
names = [a.username for a in self.selected()]
assert "ovr-valid" in names
assert "ovr-invalid" not in names
assert "ovr-error" not in names
def test_never_queried_comes_first(self):
"""**没查过的优先** —— 否则周期性刷新会永远挤掉从没查过的新账号。"""
self.account("ovr-old", queried_days_ago=30)
self.account("ovr-never")
assert self.selected()[0].username == "ovr-never"
def test_assigned_and_recent_login_lead_within_the_same_group(self):
"""没查过的仍整体优先;同一档里已分配的在前,再按最近登录。"""
stale = self.account("ovr-stale-assigned", queried_days_ago=30)
stale.assigned_to = self.admin.id
recent = self.account("ovr-recent")
recent.cookie_updated_at = datetime.now(UTC)
older = self.account("ovr-older")
older.cookie_updated_at = datetime.now(UTC) - timedelta(days=10)
assigned = self.account("ovr-assigned")
assigned.assigned_to = self.admin.id
assigned.cookie_updated_at = datetime.now(UTC) - timedelta(days=1)
self.session.commit()
names = [a.username for a in self.selected()]
assert names.index("ovr-assigned") < names.index("ovr-recent")
assert names.index("ovr-recent") < names.index("ovr-older")
assert names.index("ovr-older") < names.index("ovr-stale-assigned")
def test_recently_queried_is_skipped(self):
"""刚查过的要跳过(`refresh_days` 之内不再查)。"""
self.account("ovr-fresh", queried_days_ago=1)
names = [a.username for a in self.selected()]
assert "ovr-fresh" not in names
def test_stale_is_selected_again(self):
self.account("ovr-stale", queried_days_ago=30)
assert "ovr-stale" in [a.username for a in self.selected()]
def test_recheck_interval_comes_from_the_ui_field(self):
"""**用界面上那个「同一账号多久重检」** —— 与其它批量作业同一套口径。
之前这里读的是 `job.config["refresh_days"]`,于是界面上那个字段
对本作业是**死的**(改了没反应)—— 正是用户批评过的
「看起来能配、其实配的是不相干的东西」。
"""
self.job.interval_seconds = 100 * 24 * 3600
self.session.commit()
self.account("ovr-30d", queried_days_ago=30)
assert "ovr-30d" not in [a.username for a in self.selected()], (
"重检间隔 100 天时,30 天前查过的账号不该重新入选"
)
def test_manual_and_unmanaged_accounts_are_excluded(self):
"""人工状态(停用/归档)与「不参与自动化」的账号不该被自动碰。"""
self.account("ovr-disabled", status="disabled")
self.account("ovr-archived", status="archived")
self.account("ovr-unmanaged", auto_managed=False)
names = [a.username for a in self.selected()]
for bad in ("ovr-disabled", "ovr-archived", "ovr-unmanaged"):
assert bad not in names
def test_batch_size_limits_the_round(self):
for i in range(5):
self.account(f"ovr-batch-{i}")
self.job.batch_size = 2
self.session.commit()
assert len(self.selected()) == 2
def test_remaining_count_works_with_limit_applied(self):
"""回归:计数不能走 `build_select_query`。
那个查询已经 `LIMIT` 了,再 `order_by(None)` 会被 SQLAlchemy 拒绝:
"order_by() being called on a Query which already has LIMIT"。
(真踩过 —— 作业第一版就是这么挂的。)
"""
for i in range(4):
self.account(f"ovr-count-{i}")
self.job.batch_size = 1
self.session.commit()
assert job.count_remaining(self.session, self.job, datetime.now(UTC)) >= 4
class TestHighValueRefresh(_Base):
"""高价值结论有**自己独立的时钟** —— 资料刷新必须跟着它走。
缺口长这样:一键查询里商品接口单独失败(其它项都拿到了),
``overview_queried_at`` 被刷新了、``high_value_checked_at`` 却停在原地。
资料看起来很新,于是**再也不会被选中** —— 高价值状态永久停在旧结论。
"""
def test_stale_high_value_is_selected_even_if_overview_is_fresh(self):
"""资料 1 天前查过(很新),但高价值结论 30 天前 —— 必须重选。"""
self.account(
"ovr-hv-stale",
queried_days_ago=1,
hv_checked_days_ago=30,
hv_status="available",
)
assert "ovr-hv-stale" in [a.username for a in self.selected()]
def test_fresh_high_value_is_not_selected(self):
"""资料和高价值结论都新鲜 —— 不该被重选(避免重复请求)。"""
self.account(
"ovr-hv-fresh",
queried_days_ago=1,
hv_checked_days_ago=1,
hv_status="available",
)
assert "ovr-hv-fresh" not in [a.username for a in self.selected()]
def test_never_checked_high_value_is_not_selected_on_its_own(self):
"""从来没查到过高价值结论,**不**因此被重选。
这种情况要么是「账号根本还没查过资料」(本来就该被选中),
要么是**商品接口对这个账号稳定失败**(没配 manualID、账号没有宝典)。
后者若每轮都重选,同一批账号会反复撞墙,把真正有新欠账的挤掉。
「资料过期」那条已经兜住了它。
"""
self.account("ovr-hv-never", queried_days_ago=1)
assert "ovr-hv-never" not in [a.username for a in self.selected()]
def test_high_value_uses_the_same_ruler_as_the_ui_field(self):
"""高价值过期**用同一把尺子**(界面上那个「同一账号多久重检」)。
之前这里读的是 `job.config["refresh_days"]`,界面上那个字段对本作业
就是死的;同理,若高价值另设一个窗口,那个字段对本作业就变成半死的。
"""
self.job.interval_seconds = 100 * 24 * 3600
self.session.commit()
# 资料 30 天前(按 100 天不该重选),高价值结论 60 天前(同样不该)
self.account(
"ovr-hv-60d",
queried_days_ago=30,
hv_checked_days_ago=60,
hv_status="available",
)
assert "ovr-hv-60d" not in [a.username for a in self.selected()]
# 缩回 7 天 → 高价值 60 天前就过期了,要重选
self.job.interval_seconds = 7 * 24 * 3600
self.session.commit()
assert "ovr-hv-60d" in [a.username for a in self.selected()]
def test_high_value_refresh_still_requires_valid_ck(self):
"""高价值过期**不能**绕过 CK 有效性 —— CK 失效的查了也是白查。"""
self.account(
"ovr-hv-stale-invalid",
ck="invalid",
queried_days_ago=1,
hv_checked_days_ago=30,
hv_status="available",
)
assert "ovr-hv-stale-invalid" not in [a.username for a in self.selected()]
def test_invalidated_conclusion_is_rechecked_within_six_hours(self):
"""结论被**作废**(unknown)的账号,6 小时内要重查 —— 哪怕重检间隔是 7 天。
场景:CK 失效期间账号可能被别人手工登录并兑换(这些号共享给客服),
所以 CK 一判定失效就把结论作废。CK 恢复之后必须**尽快**重新确认 ——
否则运营会一直拿着一份「待确认」的结论,而它其实已经变了。
⚠️ 这里用的是「资料重检间隔」(7 天)之外的**另一个**窗口:
作废的结论等 7 天太久了,运营等不了。但也不能更密 ——
商城接口对某些账号稳定失败,那类账号会一直是 unknown,每轮都重选
就是反复撞墙,挤掉真正有新欠账的账号。
"""
# 资料 1 天前(很新,按 7 天不该重选),高价值结论是 10 小时前的 unknown
self.account(
"ovr-hv-invalidated",
queried_days_ago=1,
hv_checked_days_ago=10 / 24,
hv_status="unknown",
)
assert "ovr-hv-invalidated" in [a.username for a in self.selected()]
def test_invalidated_conclusion_is_not_rechecked_too_often(self):
"""作废的结论 6 小时内**不**重选 —— 不能反复撞同一个失败的接口。"""
self.account(
"ovr-hv-invalidated-fresh",
queried_days_ago=1,
hv_checked_days_ago=1 / 24, # 1 小时前
hv_status="unknown",
)
assert "ovr-hv-invalidated-fresh" not in [a.username for a in self.selected()]
class TestCircuitBreaker(_Base):
"""失败率过高就**自己把自己关掉**(风控保护)。
与「自动巡检」同口径。大面积失败通常意味着被风控或斗鱼接口抖动 ——
这时候继续跑只会把更多账号写坏,停手交给人确认才是对的。
"""
def _run_with(self, total: int, failed: int):
"""打桩执行器,只关心失败率熔断的逻辑。"""
from web.backend.models import DouyuTask
for i in range(total):
self.account(f"ovr-cb-{i}")
def fake_create(db, ids, task_type, scope, created_by, payload=None):
batch_id = "cb-batch"
for account_id in ids:
self.session.add(
DouyuTask(
batch_id=batch_id,
account_id=account_id,
task_type=task_type,
handbook_scope=scope,
status="planned",
created_by=created_by,
)
)
self.session.commit()
return batch_id, len(ids)
session = self.session # 闭包捕获:下面 _FakeRunner 里的 self 是 runner
class _FakeRunner:
def __init__(self, *a, **k):
pass
def run(self):
# 把前 `failed` 个标成失败,其余成功
rows = (
session.query(DouyuTask)
.filter(DouyuTask.batch_id == "cb-batch")
.order_by(DouyuTask.id.asc())
.all()
)
for index, row in enumerate(rows):
row.status = "failed" if index < failed else "success"
session.commit()
import web.backend.services.automation.jobs.account_overview_refresh as mod
original_create = mod.create_douyu_planned_tasks
original_runner = mod.DouyuBatchRunner
mod.create_douyu_planned_tasks = fake_create
mod.DouyuBatchRunner = _FakeRunner
try:
return job.run_account_overview_refresh(self.session, self.job)
finally:
mod.create_douyu_planned_tasks = original_create
mod.DouyuBatchRunner = original_runner
def test_high_failure_ratio_disables_the_job(self):
out = self._run_with(total=20, failed=15) # 75% 失败
self.session.refresh(self.job)
assert out["paused"] is True
assert self.job.enabled is False, "失败率过高应自动关闭作业"
assert "风控" in out["message"]
def test_normal_failure_ratio_keeps_job_running(self):
out = self._run_with(total=20, failed=1) # 5% 失败(真实的 CK 失效率)
self.session.refresh(self.job)
assert out["paused"] is False
assert "风控" not in out["message"]
def test_small_sample_does_not_trigger_breaker(self):
"""样本太少时比例没有意义(1 个失败就是 100%),不该熔断。"""
out = self._run_with(total=3, failed=3)
self.session.refresh(self.job)
assert out["paused"] is False, "样本不足时不该熔断"