P1a 把两个「间隔」混成了一个:next_run_at 用的是 job.interval_seconds,于是 作业 6 小时才跑一轮、每轮 200 条 = 800 条/天,全库 12,881 条要 16 天才能过 一遍 —— 等于没巡检,与规划里「账号 6 小时内被查一次」的目标差了两个数量级。 按规划原意修正: - TICK_SECONDS 60 → 300:作业每个 tick(5 分钟)跑一轮; - next_run_at 改为 finished + TICK_SECONDS,不再用 interval_seconds; - interval_seconds 只作为「同一行多久重新检测」的选行 cutoff(选行逻辑本来就 是这么用的,无需改); - 容量口径随之更新:5 分钟一轮 × 200 条 = 40 条/分钟 ≥ 需要的 36 条/分钟, 冷启动约 5.4 小时覆盖全库,稳态每轮一百多条; - 前端标签由「巡检周期(小时)」改为「单账号重检间隔(小时)」,说明文案写清 「每 5 分钟跑一轮」,避免运维误以为是作业周期。 同步更新规划文档 §7.2/§7.3(含两个间隔不能混的说明)与附录 C 的三段上线 步骤(立即跑一次 → 每轮 50 观察 → 每轮 200 启用)。
454 lines
16 KiB
Python
454 lines
16 KiB
Python
"""P1a 回归:CK 自动巡检。
|
|
|
|
两块约束必须一起守住:
|
|
|
|
1. **调度器行为**:只跑到点的、打开开关的作业;作业抛异常只记 failed,不能
|
|
把调度线程带崩;跑完排下一轮时间;已有配置不能被重启重置。
|
|
2. **本作业绝不改 CK**:P1a 的定位是「只检测、只标记」,所以巡检后
|
|
``cookie`` / ``status`` / ``message`` / ``attempt_*`` 必须一字未动 ——
|
|
线上全靠这条保证「自动化上线了但人工流程照旧」,回归时一旦有人顺手写了
|
|
``status``,这里要立刻红。
|
|
"""
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from web.backend.database import Base
|
|
from web.backend.deps import require_permission
|
|
from web.backend.models import Account, AutomationJob, AutomationRun, LoginTask, User
|
|
from web.backend.services.automation import (
|
|
JobSpec,
|
|
register_default_jobs,
|
|
register_job,
|
|
reset_registry,
|
|
run_due_jobs,
|
|
run_job_now,
|
|
)
|
|
from web.backend.services.automation.jobs import ck_health
|
|
|
|
|
|
class _Base:
|
|
def setup_method(self):
|
|
self.engine = create_engine("sqlite://")
|
|
Base.metadata.create_all(self.engine)
|
|
self.session = sessionmaker(bind=self.engine)()
|
|
self.admin = User(
|
|
username="admin", password_hash="h", role="super_admin", is_active=True
|
|
)
|
|
self.support = User(
|
|
username="kf", password_hash="h", role="support", is_active=True
|
|
)
|
|
self.session.add_all([self.admin, self.support])
|
|
self.session.commit()
|
|
# 注册表是模块级的,不重置会被上一个用例注册的探针作业污染。
|
|
reset_registry()
|
|
register_default_jobs()
|
|
|
|
def teardown_method(self):
|
|
self.session.close()
|
|
|
|
def make_account(self, name="acc", *, auto_managed=True, assigned_to=None):
|
|
account = Account(
|
|
username=name,
|
|
password="p",
|
|
email=f"{name}@example.com",
|
|
email_password="ep",
|
|
assigned_to=assigned_to,
|
|
auto_managed=auto_managed,
|
|
)
|
|
self.session.add(account)
|
|
self.session.commit()
|
|
return account
|
|
|
|
def make_task(
|
|
self,
|
|
account,
|
|
*,
|
|
cookie="acf_uid=1",
|
|
status="success",
|
|
attempt_status="",
|
|
ck_checked_at=None,
|
|
ck_check_status="",
|
|
):
|
|
task = LoginTask(
|
|
batch_id="b1",
|
|
account_id=account.id,
|
|
created_by=self.admin.id,
|
|
status=status,
|
|
cookie=cookie,
|
|
attempt_status=attempt_status,
|
|
ck_checked_at=ck_checked_at,
|
|
ck_check_status=ck_check_status,
|
|
)
|
|
self.session.add(task)
|
|
self.session.commit()
|
|
return task
|
|
|
|
|
|
class TestCkHealthSelection(_Base):
|
|
def _selected_ids(self, job):
|
|
return [
|
|
t.id for t in ck_health._select_tasks(self.session, job, datetime.now(UTC))
|
|
]
|
|
|
|
def test_picks_only_rows_that_still_hold_cookie(self):
|
|
job = self._job()
|
|
with_cookie = self.make_task(self.make_account("a"))
|
|
self.make_task(self.make_account("b"), cookie="")
|
|
assert self._selected_ids(job) == [with_cookie.id]
|
|
|
|
def test_skips_accounts_with_inflight_attempt(self):
|
|
job = self._job()
|
|
idle = self.make_task(self.make_account("a"))
|
|
self.make_task(self.make_account("b"), attempt_status="running")
|
|
self.make_task(self.make_account("c"), attempt_status="pending")
|
|
assert self._selected_ids(job) == [idle.id]
|
|
|
|
def test_skips_accounts_opted_out_of_automation(self):
|
|
job = self._job()
|
|
managed = self.make_task(self.make_account("a"))
|
|
self.make_task(self.make_account("b", auto_managed=False))
|
|
assert self._selected_ids(job) == [managed.id]
|
|
|
|
def test_respects_interval_and_prefers_stalest(self):
|
|
job = self._job()
|
|
fresh = self.make_task(
|
|
self.make_account("fresh"), ck_checked_at=datetime.now(UTC)
|
|
)
|
|
stale = self.make_task(
|
|
self.make_account("stale"),
|
|
ck_checked_at=datetime.now(UTC) - timedelta(hours=30),
|
|
)
|
|
never = self.make_task(self.make_account("never"), ck_checked_at=None)
|
|
ids = self._selected_ids(job)
|
|
assert fresh.id not in ids
|
|
# 从没检测过的排最前,其次是久未检测的
|
|
assert ids[0] == never.id
|
|
assert stale.id in ids
|
|
|
|
def test_batch_size_caps_the_round(self):
|
|
job = self._job()
|
|
job.batch_size = 2
|
|
for index in range(5):
|
|
self.make_task(self.make_account(f"a{index}"))
|
|
assert len(self._selected_ids(job)) == 2
|
|
|
|
def _job(self):
|
|
return AutomationJob(
|
|
key=ck_health.JOB_KEY, interval_seconds=6 * 3600, batch_size=200
|
|
)
|
|
|
|
|
|
class TestCkHealthNeverTouchesCookie(_Base):
|
|
def test_patrol_only_writes_check_fields(self):
|
|
job = self._job()
|
|
account = self.make_account("acc")
|
|
task = self.make_task(account, attempt_status="failed", ck_check_status="")
|
|
before = (task.cookie, task.status, task.message, task.attempt_status)
|
|
|
|
ck_health.check_douyu_cookie = _fake_check(valid=True)
|
|
try:
|
|
counters = ck_health.run_ck_health(self.session, job)
|
|
finally:
|
|
ck_health.check_douyu_cookie = _REAL_CHECK
|
|
|
|
self.session.refresh(task)
|
|
assert counters["scanned"] == 1 and counters["valid"] == 1
|
|
assert task.ck_check_status == "valid"
|
|
assert task.ck_checked_at is not None
|
|
assert task.ck_check_result["nickname"] == "测试"
|
|
# 核心断言:CK 本体与尝试状态一字未动
|
|
assert (task.cookie, task.status, task.message, task.attempt_status) == before
|
|
|
|
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)
|
|
try:
|
|
counters = ck_health.run_ck_health(self.session, job)
|
|
finally:
|
|
ck_health.check_douyu_cookie = _REAL_CHECK
|
|
self.session.refresh(task)
|
|
assert counters["invalid"] == 1
|
|
assert task.ck_check_status == "invalid"
|
|
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"))
|
|
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
|
|
|
|
def _job(self):
|
|
return AutomationJob(
|
|
key=ck_health.JOB_KEY, interval_seconds=6 * 3600, batch_size=200
|
|
)
|
|
|
|
|
|
class TestScheduler(_Base):
|
|
def _register_probe(self, calls):
|
|
def run(db, job):
|
|
calls.append(job.key)
|
|
return {"scanned": 3, "valid": 2, "invalid": 1, "message": "ok"}
|
|
|
|
register_job(
|
|
JobSpec(
|
|
key="probe",
|
|
title="探针",
|
|
interval_seconds=3600,
|
|
batch_size=1,
|
|
concurrency=1,
|
|
run=run,
|
|
)
|
|
)
|
|
|
|
def test_only_enabled_jobs_run(self):
|
|
from web.backend.services.automation import ensure_jobs
|
|
|
|
calls: list[str] = []
|
|
self._register_probe(calls)
|
|
ensure_jobs(self.session)
|
|
self.session.query(AutomationJob).filter(AutomationJob.key == "probe").update(
|
|
{"enabled": True}
|
|
)
|
|
self.session.commit()
|
|
|
|
run_due_jobs(self.session)
|
|
assert calls == ["probe"]
|
|
|
|
calls.clear()
|
|
run_due_jobs(self.session)
|
|
# 上一轮已排了 next_run_at,未到点不再跑
|
|
assert calls == []
|
|
|
|
def test_job_failure_is_recorded_not_raised(self):
|
|
from web.backend.services.automation import ensure_jobs
|
|
|
|
def boom(db, job):
|
|
raise RuntimeError("接口挂了")
|
|
|
|
register_job(
|
|
JobSpec(
|
|
key="boom",
|
|
title="会炸的作业",
|
|
interval_seconds=3600,
|
|
batch_size=1,
|
|
concurrency=1,
|
|
run=boom,
|
|
)
|
|
)
|
|
ensure_jobs(self.session)
|
|
self.session.query(AutomationJob).filter(AutomationJob.key == "boom").update(
|
|
{"enabled": True}
|
|
)
|
|
self.session.commit()
|
|
|
|
outcomes = run_due_jobs(self.session)
|
|
assert outcomes[0]["status"] == "failed"
|
|
run = self.session.query(AutomationRun).one()
|
|
assert run.status == "failed" and "接口挂了" in run.message
|
|
|
|
def test_next_run_follows_tick_and_counters_persist(self):
|
|
from web.backend.services.automation import ensure_jobs
|
|
|
|
calls: list[str] = []
|
|
self._register_probe(calls)
|
|
ensure_jobs(self.session)
|
|
self.session.query(AutomationJob).filter(AutomationJob.key == "probe").update(
|
|
{"enabled": True}
|
|
)
|
|
self.session.commit()
|
|
|
|
run_due_jobs(self.session)
|
|
job = (
|
|
self.session.query(AutomationJob).filter(AutomationJob.key == "probe").one()
|
|
)
|
|
assert job.last_status == "success"
|
|
# 下一轮在一个 tick 之后(作业每个 tick 都跑),与 interval_seconds 无关
|
|
from web.backend.services.automation.scheduler import TICK_SECONDS
|
|
|
|
assert _aware(job.next_run_at) - _aware(job.last_run_at) == timedelta(
|
|
seconds=TICK_SECONDS
|
|
)
|
|
run = self.session.query(AutomationRun).one()
|
|
assert (run.scanned, run.valid, run.invalid) == (3, 2, 1)
|
|
|
|
def test_ensure_jobs_keeps_operator_settings(self):
|
|
from web.backend.services.automation import ensure_jobs
|
|
|
|
ensure_jobs(self.session)
|
|
job = (
|
|
self.session.query(AutomationJob)
|
|
.filter(AutomationJob.key == ck_health.JOB_KEY)
|
|
.one()
|
|
)
|
|
job.enabled = True
|
|
job.batch_size = 7
|
|
self.session.commit()
|
|
|
|
ensure_jobs(self.session) # 模拟重启
|
|
self.session.refresh(job)
|
|
assert job.enabled is True and job.batch_size == 7
|
|
|
|
def test_run_job_now_ignores_switch(self):
|
|
from web.backend.services.automation import ensure_jobs
|
|
|
|
ensure_jobs(self.session)
|
|
outcome = run_job_now(self.session, ck_health.JOB_KEY)
|
|
assert outcome["job_key"] == ck_health.JOB_KEY
|
|
assert self.session.query(AutomationRun).count() == 1
|
|
|
|
def test_run_job_now_unknown_key(self):
|
|
with pytest.raises(KeyError):
|
|
run_job_now(self.session, "nope")
|
|
|
|
|
|
class TestAutomationPermissions(_Base):
|
|
def test_view_allows_operation_denies_support(self):
|
|
from web.backend.permissions import ROLE_PERMISSIONS
|
|
|
|
assert "automation:view" in ROLE_PERMISSIONS["operation"]
|
|
assert "automation:view" not in ROLE_PERMISSIONS["support"]
|
|
assert "automation:manage" in ROLE_PERMISSIONS["super_admin"]
|
|
assert "automation:manage" not in ROLE_PERMISSIONS["operation"]
|
|
|
|
def test_manage_dependency_rejects_operator(self):
|
|
operator = User(
|
|
username="op", password_hash="h", role="operation", is_active=True
|
|
)
|
|
checker = require_permission("automation:manage")
|
|
with pytest.raises(HTTPException) as exc:
|
|
checker(current_user=operator)
|
|
assert exc.value.status_code == 403
|
|
|
|
def test_view_dependency_accepts_operator(self):
|
|
operator = User(
|
|
username="op", password_hash="h", role="operation", is_active=True
|
|
)
|
|
checker = require_permission("automation:view")
|
|
assert checker(current_user=operator).id == operator.id
|
|
|
|
|
|
def _aware(value):
|
|
"""库里读出的是 naive UTC,比较前补时区。"""
|
|
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
|
|
|
|
|
_REAL_CHECK = ck_health.check_douyu_cookie
|
|
|
|
|
|
def _fake_check(*, valid=True, raises=False):
|
|
def fake(cookie: str) -> dict:
|
|
if raises:
|
|
raise RuntimeError("网络异常")
|
|
return {
|
|
"valid": valid,
|
|
"message": "ok" if valid else "Cookie 已失效",
|
|
"fish_ball": 1,
|
|
"nickname": "测试",
|
|
"level": 30,
|
|
"checked_at": datetime.now(UTC),
|
|
}
|
|
|
|
return fake
|
|
|
|
|
|
class TestAutomationApi(_Base):
|
|
"""接口层:序列化字段、开关落库、手动触发不受开关限制。"""
|
|
|
|
def _api(self):
|
|
from web.backend.routers import automation as api
|
|
|
|
return api
|
|
|
|
def test_list_jobs_exposes_scheduler_and_job_state(self):
|
|
api = self._api()
|
|
payload = api.list_jobs(db=self.session, current=self.admin)
|
|
assert payload["scheduler"]["tick_seconds"] == 300
|
|
assert payload["scheduler"]["running"] is False
|
|
keys = [item["key"] for item in payload["jobs"]]
|
|
assert keys == [ck_health.JOB_KEY]
|
|
job = payload["jobs"][0]
|
|
assert job["enabled"] is False
|
|
assert job["interval_seconds"] == 6 * 3600
|
|
assert job["batch_size"] == 200
|
|
assert job["latest_run"] is None
|
|
|
|
def test_update_job_persists_and_schedules_next_run(self):
|
|
api = self._api()
|
|
api.update_job(
|
|
ck_health.JOB_KEY,
|
|
api.JobUpdate(
|
|
enabled=True, interval_seconds=3600, batch_size=5, concurrency=2
|
|
),
|
|
db=self.session,
|
|
current=self.admin,
|
|
)
|
|
job = self.session.query(AutomationJob).one()
|
|
assert job.enabled is True
|
|
assert job.interval_seconds == 3600
|
|
assert job.batch_size == 5
|
|
assert job.next_run_at is not None # 打开开关就排了一轮
|
|
|
|
# 审计留痕
|
|
from web.backend.models import AuditLog
|
|
|
|
entry = (
|
|
self.session.query(AuditLog)
|
|
.filter(AuditLog.action == "automation:update")
|
|
.one()
|
|
)
|
|
assert entry.target == ck_health.JOB_KEY
|
|
|
|
def test_run_endpoint_records_run_and_audit(self):
|
|
api = self._api()
|
|
self.make_task(self.make_account("a"))
|
|
ck_health.check_douyu_cookie = _fake_check(valid=True)
|
|
try:
|
|
outcome = api.run_job(
|
|
ck_health.JOB_KEY, db=self.session, current=self.admin
|
|
)
|
|
finally:
|
|
ck_health.check_douyu_cookie = _REAL_CHECK
|
|
assert outcome["status"] == "success"
|
|
assert outcome["scanned"] == 1
|
|
assert self.session.query(AutomationRun).count() == 1
|
|
|
|
from web.backend.models import AuditLog
|
|
|
|
assert (
|
|
self.session.query(AuditLog)
|
|
.filter(AuditLog.action == "automation:run")
|
|
.count()
|
|
== 1
|
|
)
|
|
|
|
def test_run_endpoint_unknown_job_returns_404(self):
|
|
api = self._api()
|
|
with pytest.raises(HTTPException) as exc:
|
|
api.run_job("nope", db=self.session, current=self.admin)
|
|
assert exc.value.status_code == 404
|
|
|
|
def test_runs_endpoint_filters_by_job_key(self):
|
|
api = self._api()
|
|
self.session.add(
|
|
AutomationRun(job_key=ck_health.JOB_KEY, status="success", scanned=1)
|
|
)
|
|
self.session.add(AutomationRun(job_key="other", status="failed"))
|
|
self.session.commit()
|
|
payload = api.list_runs(
|
|
job_key=ck_health.JOB_KEY, limit=50, db=self.session, current=self.admin
|
|
)
|
|
assert len(payload["items"]) == 1
|
|
assert payload["items"][0]["job_key"] == ck_health.JOB_KEY
|