diff --git a/tests/test_automation_ck_relogin.py b/tests/test_automation_ck_relogin.py index dfb63fc..8baca93 100644 --- a/tests/test_automation_ck_relogin.py +++ b/tests/test_automation_ck_relogin.py @@ -19,6 +19,7 @@ from sqlalchemy.orm import sessionmaker from web.backend.database import Base from web.backend.models import Account, AutomationJob, LoginTask, User +from web.backend.services.automation import register_default_jobs, reset_registry from web.backend.services.automation.jobs import ck_relogin @@ -27,6 +28,10 @@ class _Base: self.engine = create_engine("sqlite://") Base.metadata.create_all(self.engine) self.session = sessionmaker(bind=self.engine)() + # 作业注册表是模块级全局,测试之间会互相污染;重置成生产启动时的状态, + # 否则 list_jobs() 里的 ensure_jobs() 建不出作业行。 + reset_registry() + register_default_jobs() self.admin = User( username="admin", password_hash="h", role="super_admin", is_active=True ) @@ -375,3 +380,24 @@ class TestManualQueueApi(_Base): def test_empty_queue(self): payload = self._queue() assert payload["total"] == 0 and payload["groups"] == [] + + +class TestLinkageWithHealthJob(_Base): + """两个作业是上下游:jobs 接口要暴露「当前有多少条等着自动重登」。 + + 页面靠这个数字把巡检 → 重登的关系显性化;没有它,两个开关看起来像互不相干。 + """ + + def test_pending_relogin_counts_only_actionable_invalid(self): + from web.backend.routers import automation as api + + ready = self.make_task("ready") + self.make_task("valid", ck_check_status="valid") + self.make_task("nocookie", cookie="") + self.make_task("optedout", auto_managed=False) + + payload = api.list_jobs(db=self.session, current=self.admin) + # 只有 ready 同时满足:invalid + 有 CK + 允许自动管理 + assert payload["pending_relogin"] == 1 + assert any(job["key"] == ck_relogin.JOB_KEY for job in payload["jobs"]) + assert ready.id is not None diff --git a/tests/test_douyu_email_code_pick.py b/tests/test_douyu_email_code_pick.py index baa48e1..7900e91 100644 --- a/tests/test_douyu_email_code_pick.py +++ b/tests/test_douyu_email_code_pick.py @@ -12,7 +12,7 @@ 日期解析按本地时间,仅在拿不到快照时才用时间兜底。 """ -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from core.douyu.email_verifier import EmailVerifier @@ -79,10 +79,18 @@ class TestEmailCodePick: assert verifier.read_uids == [4] def test_send_time_filter_skips_older_mail(self): - """没有 UID 快照时,比发送时间早的邮件必须被跳过。""" + """没有 UID 快照时,比发送时间早的邮件必须被跳过。 + + 注意这里必须用**真实的时间减法**构造「30 分钟前」,不能写成 + ``max(0, now.minute - 30)``:分钟数小于 30 时它会退化成当前整点 + (如 21:00:12 得到 "今天 21:00"),只比 now 早十几秒,断言就随 + 「当前是几点」偶发失败 —— 每小时前 30 分钟必红。跨天时用「昨天」。 + """ now = datetime.now(tz=UTC).astimezone() + earlier = now - timedelta(minutes=30) + prefix = "今天" if earlier.date() == now.date() else "昨天" verifier = _FakeVerifier( - [_msg(1, f"今天 {now.hour:02d}:{max(0, now.minute - 30):02d}")], + [_msg(1, f"{prefix} {earlier.hour:02d}:{earlier.minute:02d}")], {1: "111111"}, ) diff --git a/web/backend/routers/automation.py b/web/backend/routers/automation.py index 53f0149..10ac038 100644 --- a/web/backend/routers/automation.py +++ b/web/backend/routers/automation.py @@ -86,8 +86,21 @@ def list_jobs( db.query(AutomationRun).order_by(AutomationRun.id.desc()).limit(200).all() ): latest.setdefault(run.job_key, run) + # 巡检与重登是两个作业,但通过数据库联动:巡检把行标成 invalid,重登作业的 + # 候选条件正是这个字段。这里把「当前有多少条等着重登」一并返回,页面才能把 + # 这层上下游关系显示出来,而不是让人以为两个开关互不相干。 + from ..services.automation.jobs.ck_relogin import build_select_query + + pending_relogin = 0 + relogin_job = next((job for job in jobs if job.key == "ck_relogin"), None) + if relogin_job is not None: + pending_relogin = build_select_query( + db, relogin_job, datetime.now(UTC), max_attempts=10**6 + ).count() + return { "scheduler": scheduler_status(), + "pending_relogin": pending_relogin, "jobs": [ { **_job_out(job), diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 85cc4b1..8c6bb9d 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -883,6 +883,8 @@ export interface AutomationSchedulerStatus { export interface AutomationJobsResponse { scheduler: AutomationSchedulerStatus; + /** 巡检已标记失效、正等自动重登处理的条数(体现两个作业的上下游关系)。 */ + pending_relogin: number; jobs: AutomationJobItem[]; } diff --git a/web/frontend/src/pages/AutomationPage.tsx b/web/frontend/src/pages/AutomationPage.tsx index 2762c78..4024b43 100644 --- a/web/frontend/src/pages/AutomationPage.tsx +++ b/web/frontend/src/pages/AutomationPage.tsx @@ -45,6 +45,7 @@ export default function AutomationPage() { const [runs, setRuns] = useState([]); const [manualGroups, setManualGroups] = useState([]); const [manualTotal, setManualTotal] = useState(0); + const [pendingRelogin, setPendingRelogin] = useState(0); const [loading, setLoading] = useState(false); const [savingKey, setSavingKey] = useState(null); const [runningKey, setRunningKey] = useState(null); @@ -57,6 +58,7 @@ export default function AutomationPage() { try { const data = await automationApi.jobs(); setJobs(data.jobs); + setPendingRelogin(data.pending_relogin ?? 0); setSchedulerEnabled(data.scheduler.enabled); setSchedulerRunning(data.scheduler.running); const history = await automationApi.runs({ limit: 50 }); @@ -226,8 +228,8 @@ export default function AutomationPage() { type="info" showIcon style={{ marginBottom: 16 }} - message="巡检只检测、不改 CK" - description="每 5 分钟跑一轮,每轮按「每轮条数」上限挑最久没检测的账号;「单账号重检间隔」指同一个账号最多多久重新检测一次。本作业只标记失效账号,不会替换任何 Cookie、也不会改账号状态,重登仍由人工在「CK 检测与重登」页面操作。" + message="巡检只检测、不改 CK;发现失效后由「CK 自动重登」接手" + description="两个作业是联动的上下游:巡检把失效账号标记为 invalid,「CK 自动重登」的候选条件正是这个标记,所以巡检一发现失效,重登作业在下一个周期就会把它救回来(需打开重登开关)。刻意分成两个作业是为了止损粒度 —— 巡检只读、零风险;重登会真实登录(消耗邮箱验证码、可能遇到极验),出问题可单独关掉重登而不影响检测。巡检本身不会替换任何 Cookie、也不会改账号状态。" /> {jobs.map((job) => ( @@ -235,6 +237,18 @@ export default function AutomationPage() { key={job.key} type="inner" title={job.title} + extra={ + job.key === "ck_relogin" ? ( + + 上游:巡检标记为失效且有 CK 的账号,当前待重登{" "} + {pendingRelogin} 个 + + ) : job.key === "ck_health" ? ( + + 下游:CK 自动重登(发现失效后自动接手) + + ) : null + } style={{ marginBottom: 16 }} >