fix(automation): 巡检区分「查不到」与「失效」,并在大面积失败时自动暂停
P1a 原本把「检测没通过」一律写成 invalid。但 check_douyu_cookie 的失败分两类: 斗鱼明确回「未登录/登陆异常」(CK 真失效),以及请求超时/响应异常(我们查不到)。 一视同仁的后果是:斗鱼接口抖动或被风控时,每 5 分钟 200 个、一小时就能把两千多个 好账号标成失效,客服会误判成「CK 大面积挂掉」。规划里的失败率熔断只给了 P1b, P1a 漏了。 改动: - 新增 classify_result():valid / invalid(命中斗鱼鉴权类说法)/ error(其余); - error 时**不覆盖该行原来的 ck_check_status**,只更新 ck_check_result(带 verdict 字段)与 ck_checked_at,并计入本轮 skipped; - 一轮 error 占比 > 30% 且条数 >= 20 时自动把作业 enabled 置 false,message 与 detail.paused 写明原因,避免继续污染; - 真实死号占比高**不触发**暂停 —— 首轮 100% invalid 是预期情况(选行「从没检测过 的优先」,积压的是两个月前批量导入、attempt_status 为空、从未登录的陈旧账号, 生产实测 200 条错误信息一致为斗鱼侧「用户未登录」)。 新增 6 条测试:分类正确性、error 保留原结论、异常不再标失效、大面积失败自动暂停、 真实死号不暂停。全量 271 条通过。
This commit is contained in:
@@ -183,7 +183,11 @@ class TestCkHealthNeverTouchesCookie(_Base):
|
||||
def test_invalid_result_marks_invalid_but_keeps_cookie(self):
|
||||
job = self._job()
|
||||
task = self.make_task(self.make_account("acc"))
|
||||
ck_health.check_douyu_cookie = _fake_check(valid=False)
|
||||
# 必须是鉴权类失败(斗鱼说未登录)才会被标成 invalid
|
||||
ck_health.check_douyu_cookie = _fake_check(
|
||||
valid=False,
|
||||
message="鱼丸接口: 账户登陆异常,请重新登陆;等级接口: 用户未登录",
|
||||
)
|
||||
try:
|
||||
counters = ck_health.run_ck_health(self.session, job)
|
||||
finally:
|
||||
@@ -194,16 +198,23 @@ class TestCkHealthNeverTouchesCookie(_Base):
|
||||
assert task.cookie == "acf_uid=1"
|
||||
|
||||
def test_detection_exception_does_not_abort_round(self):
|
||||
"""单个账号检测抛异常不影响整轮,但也不能把它标成失效。"""
|
||||
job = self._job()
|
||||
self.make_task(self.make_account("a"))
|
||||
self.make_task(self.make_account("b"))
|
||||
first = self.make_task(self.make_account("a"), ck_check_status="valid")
|
||||
second = self.make_task(self.make_account("b"), ck_check_status="valid")
|
||||
ck_health.check_douyu_cookie = _fake_check(raises=True)
|
||||
try:
|
||||
counters = ck_health.run_ck_health(self.session, job)
|
||||
finally:
|
||||
ck_health.check_douyu_cookie = _REAL_CHECK
|
||||
assert counters["scanned"] == 2
|
||||
assert counters["invalid"] == 2
|
||||
# 查不到不等于失效:两行都保留原结论,只记录这次异常
|
||||
assert counters["invalid"] == 0
|
||||
assert counters["skipped"] == 2
|
||||
for task in (first, second):
|
||||
self.session.refresh(task)
|
||||
assert task.ck_check_status == "valid"
|
||||
assert task.ck_check_result["verdict"] == "error"
|
||||
|
||||
def _job(self):
|
||||
return AutomationJob(
|
||||
@@ -363,13 +374,15 @@ def _aware(value):
|
||||
_REAL_CHECK = ck_health.check_douyu_cookie
|
||||
|
||||
|
||||
def _fake_check(*, valid=True, raises=False):
|
||||
def _fake_check(*, valid=True, raises=False, message=None):
|
||||
def fake(cookie: str) -> dict:
|
||||
if raises:
|
||||
raise RuntimeError("网络异常")
|
||||
return {
|
||||
"valid": valid,
|
||||
"message": "ok" if valid else "Cookie 已失效",
|
||||
"message": message
|
||||
if message is not None
|
||||
else ("ok" if valid else "Cookie 已失效"),
|
||||
"fish_ball": 1,
|
||||
"nickname": "测试",
|
||||
"level": 30,
|
||||
@@ -467,3 +480,104 @@ class TestAutomationApi(_Base):
|
||||
)
|
||||
assert len(payload["items"]) == 1
|
||||
assert payload["items"][0]["job_key"] == ck_health.JOB_KEY
|
||||
|
||||
|
||||
class TestFailureClassification(_Base):
|
||||
"""「查不到」不等于「失效」:非鉴权类失败不能覆盖原来的结论。
|
||||
|
||||
斗鱼接口抖动/被风控时,如果不加区分地写 invalid,一小时就能把上千个好账号
|
||||
标成失效,客服会误判成 CK 大面积挂掉。这里盯三件事:分类正确、error 不改
|
||||
结论、失败占比过高时自动暂停作业。
|
||||
"""
|
||||
|
||||
def test_auth_failure_message_counts_as_invalid(self):
|
||||
assert (
|
||||
ck_health.classify_result(
|
||||
{
|
||||
"valid": False,
|
||||
"message": "鱼丸接口: 账户登陆异常,请重新登陆;等级接口: 用户未登录",
|
||||
}
|
||||
)
|
||||
== "invalid"
|
||||
)
|
||||
|
||||
def test_network_failure_counts_as_error_not_invalid(self):
|
||||
assert (
|
||||
ck_health.classify_result(
|
||||
{
|
||||
"valid": False,
|
||||
"message": "鱼丸接口: 请求失败: timeout;等级接口: 请求失败: timeout",
|
||||
}
|
||||
)
|
||||
== "error"
|
||||
)
|
||||
assert (
|
||||
ck_health.classify_result({"valid": False, "message": "响应异常"})
|
||||
== "error"
|
||||
)
|
||||
|
||||
def test_valid_still_valid(self):
|
||||
assert ck_health.classify_result({"valid": True, "message": "有效"}) == "valid"
|
||||
|
||||
def test_error_keeps_previous_verdict_but_marks_checked(self):
|
||||
job = self._job()
|
||||
task = self.make_task(self.make_account("acc"), ck_check_status="valid")
|
||||
ck_health.check_douyu_cookie = _fake_check(
|
||||
valid=False, message="请求失败: timeout"
|
||||
)
|
||||
try:
|
||||
counters = ck_health.run_ck_health(self.session, job)
|
||||
finally:
|
||||
ck_health.check_douyu_cookie = _REAL_CHECK
|
||||
self.session.refresh(task)
|
||||
assert counters["valid"] == 0 and counters["invalid"] == 0
|
||||
assert counters["skipped"] == 1 # error 计入 skipped
|
||||
assert task.ck_check_status == "valid" # 原结论保留
|
||||
assert task.ck_check_result["verdict"] == "error" # 但记录了这次查不到
|
||||
assert task.ck_checked_at is not None
|
||||
|
||||
def test_mass_network_failure_pauses_the_job(self):
|
||||
job = self._job()
|
||||
job.enabled = True
|
||||
job.batch_size = 40
|
||||
for index in range(40):
|
||||
self.make_task(self.make_account(f"a{index}"))
|
||||
ck_health.check_douyu_cookie = _fake_check(
|
||||
valid=False, message="请求失败: Connection refused"
|
||||
)
|
||||
try:
|
||||
counters = ck_health.run_ck_health(self.session, job)
|
||||
finally:
|
||||
ck_health.check_douyu_cookie = _REAL_CHECK
|
||||
assert counters["skipped"] == 40
|
||||
assert counters["detail"]["paused"] is True
|
||||
assert job.enabled is False # 自动暂停,不再继续污染
|
||||
assert "已自动暂停" in counters["message"]
|
||||
|
||||
def test_normal_invalid_does_not_pause(self):
|
||||
"""真的都是死号时不能暂停 —— 首轮 100% invalid 是合法且预期的情况。"""
|
||||
job = self._job()
|
||||
job.enabled = True
|
||||
job.batch_size = 40
|
||||
for index in range(40):
|
||||
self.make_task(self.make_account(f"a{index}"))
|
||||
ck_health.check_douyu_cookie = _fake_check(
|
||||
valid=False,
|
||||
message="鱼丸接口: 账户登陆异常,请重新登陆;等级接口: 用户未登录",
|
||||
)
|
||||
try:
|
||||
counters = ck_health.run_ck_health(self.session, job)
|
||||
finally:
|
||||
ck_health.check_douyu_cookie = _REAL_CHECK
|
||||
assert counters["invalid"] == 40
|
||||
assert counters["skipped"] == 0
|
||||
assert job.enabled is True
|
||||
assert counters["detail"]["paused"] is False
|
||||
|
||||
def _job(self):
|
||||
return AutomationJob(
|
||||
key=ck_health.JOB_KEY,
|
||||
interval_seconds=6 * 3600,
|
||||
batch_size=200,
|
||||
concurrency=3,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user