Files
live-hub-py/web/backend/routers/automation.py
T
yml2213 8fc8e623b9 feat(automation): CK 自动重登(P1b)+ 失败分型与待人工队列
P1a 的巡检只产出「谁失效了」的清单,不兑现修复:1,356 个失效账号仍要人工逐个
选集→点重登→过滑块。线上实测 20 个失效账号重登 20/20 成功(2 分 13 秒、零极验),
证实失效是会话级过期而非凭证问题,可救量约 1,350。

后端:
- 迁移 20261002_0038:login_tasks 加 relogin_failure_kind(枚举),供作业决定
  「继续退避重试」还是「转人工」;不用 attempt_message 做字符串匹配(文案一改
  就失效,页面也要按类型筛选);
- jobs/ck_relogin.py:
  - 候选 = 巡检判定 invalid + 允许自动管理 + 无在途尝试 + 退避到期 + 未超每日上限
    + **未转人工**(极验/凭证问题不再自动重登);
  - 复用 LoginBatchRunner(mode="relogin"),成功才替换 CK(沿用既有安全语义)。
    自动化身份取最早的超管做 created_by,权限给 login:view_all 以免受归属限制;
    总时长上限压到 240 秒,避免长时间占住调度线程;
  - settle_results() 独立成纯函数便于单测:成功清空退避状态;失败按分型分流 ——
    captcha/credential 转人工(不排期)、email_quota 退避到次日零点+随机抖动、
    email_code/interrupted/network 走 1h→3h→12h→24h 阶梯;次数用尽也停下等人工;
  - 未知文案默认按可重试处理(有每日上限兜底),别把可能只是抖动的账号推给人工;
- 注册第二个作业「CK 自动重登」(batch 10 / 并发 3,**默认关闭**);
- 新增 GET /api/automation/manual-queue:按原因分组列出待人工处理的账号。可重试
  类型但已无下次排期的(次数用尽)单独成组 —— 否则会混进「验证码失败」里,客服
  看不出这个已经不会再自动试了。

前端:
- /automation 页新增「待人工处理」卡片,按分型分组展示(极验要过滑块、凭证要核对、
  次数用尽要人工确认),各带账号与最近失败原因。

测试:新增 26 条(分型顺序陷阱「上限」优先于「验证码」、退避阶梯、配额退避次日、
候选筛选、MySQL 方言编译、结算、待人工队列分组)。
提交前全量 297 通过、ruff 全绿、前端 tsc 通过。

注意:提交时另一路「测试改用 MySQL」的改造正在同一工作区进行,本次刻意只暂存
本功能相关文件,未触碰 tests/conftest.py、tests/support/ 及对方已转换的测试文件。
2026-10-02 20:51:19 +08:00

270 lines
9.1 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.
"""自动化作业接口。
设计上「先能看、再能开」:查看需要 ``automation:view``(运营也有),改配置与
手动触发需要 ``automation:manage``(仅超管)。作业默认关闭,打开后由进程内
调度器按间隔执行。
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import require_permission
from ..models import Account, AutomationJob, AutomationRun, LoginTask, User
from ..services.audit_service import record_audit
from ..services.automation import (
ensure_jobs,
registered_jobs,
run_job_now,
scheduler_status,
)
router = APIRouter(prefix="/api/automation", tags=["自动化"])
MAX_BATCH_SIZE = 2000
MIN_INTERVAL_SECONDS = 300
class JobUpdate(BaseModel):
"""作业配置更新;只传要改的字段。"""
enabled: bool | None = None
interval_seconds: int | None = Field(default=None, ge=MIN_INTERVAL_SECONDS)
batch_size: int | None = Field(default=None, ge=1, le=MAX_BATCH_SIZE)
concurrency: int | None = Field(default=None, ge=1, le=16)
def _job_out(job: AutomationJob) -> dict[str, Any]:
return {
"key": job.key,
"title": job.title,
"enabled": bool(job.enabled),
"interval_seconds": int(job.interval_seconds or 0),
"batch_size": int(job.batch_size or 0),
"concurrency": int(job.concurrency or 0),
"last_run_at": job.last_run_at.isoformat() if job.last_run_at else None,
"next_run_at": job.next_run_at.isoformat() if job.next_run_at else None,
"last_status": job.last_status or "",
"last_message": job.last_message or "",
"registered": job.key in registered_jobs(),
}
def _run_out(run: AutomationRun) -> dict[str, Any]:
return {
"id": run.id,
"job_key": run.job_key,
"started_at": run.started_at.isoformat() if run.started_at else None,
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"status": run.status or "",
"scanned": int(run.scanned or 0),
"valid": int(run.valid or 0),
"invalid": int(run.invalid or 0),
"skipped": int(run.skipped or 0),
"message": run.message or "",
"detail": run.detail or None,
}
@router.get("/jobs")
def list_jobs(
db: Session = Depends(get_db),
current: User = Depends(require_permission("automation:view")),
):
"""列出作业配置、最近一次结果与调度器状态。"""
ensure_jobs(db)
jobs = db.query(AutomationJob).order_by(AutomationJob.key.asc()).all()
latest: dict[str, AutomationRun] = {}
for run in (
db.query(AutomationRun).order_by(AutomationRun.id.desc()).limit(200).all()
):
latest.setdefault(run.job_key, run)
return {
"scheduler": scheduler_status(),
"jobs": [
{
**_job_out(job),
"latest_run": _run_out(latest[job.key]) if job.key in latest else None,
}
for job in jobs
],
}
@router.patch("/jobs/{key}")
def update_job(
key: str,
payload: JobUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("automation:manage")),
):
"""改开关 / 间隔 / 批量 / 并发。打开开关时排定下一轮时间。"""
ensure_jobs(db)
job = db.query(AutomationJob).filter(AutomationJob.key == key).first()
if job is None:
raise HTTPException(status_code=404, detail="作业不存在")
changed: dict[str, Any] = {}
if payload.enabled is not None and bool(payload.enabled) != bool(job.enabled):
job.enabled = bool(payload.enabled)
changed["enabled"] = job.enabled
# 刚打开时排一轮:不早于一个 tick,避免打开瞬间就压满 CPU。
job.next_run_at = datetime.now(UTC) + _tick_delta() if job.enabled else None
if payload.interval_seconds is not None:
changed["interval_seconds"] = payload.interval_seconds
job.interval_seconds = payload.interval_seconds
if payload.batch_size is not None:
changed["batch_size"] = payload.batch_size
job.batch_size = payload.batch_size
if payload.concurrency is not None:
changed["concurrency"] = payload.concurrency
job.concurrency = payload.concurrency
if changed:
job.updated_at = datetime.now(UTC)
record_audit(
db,
current,
action="automation:update",
target=key,
detail=changed,
)
db.commit()
return _job_out(job)
@router.post("/jobs/{key}/run")
def run_job(
key: str,
db: Session = Depends(get_db),
current: User = Depends(require_permission("automation:manage")),
):
"""立刻跑一轮(不看 enabled)。用于正式打开开关前先试跑。"""
ensure_jobs(db)
try:
outcome = run_job_now(db, key)
except KeyError:
raise HTTPException(status_code=404, detail="作业不存在") from None
record_audit(
db,
current,
action="automation:run",
target=key,
detail={
"status": outcome.get("status"),
"scanned": outcome.get("scanned"),
"valid": outcome.get("valid"),
"invalid": outcome.get("invalid"),
"message": outcome.get("message"),
},
)
db.commit()
return outcome
# 失败分型 → 页面展示用的中文说明。「转人工」的两种要写清为什么,
# 否则客服不知道该做什么。
MANUAL_KIND_LABELS = {
"captcha": "极验滑块:需要人工过一次验证",
"credential": "凭证问题:需要核对账号密码 / 邮箱",
"": "重试次数用尽:需要人工确认账号是否还可用",
}
def _manual_kind_label(kind: str) -> str:
return MANUAL_KIND_LABELS.get(kind, f"待确认({kind})")
@router.get("/manual-queue")
def manual_queue(
limit: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
current: User = Depends(require_permission("automation:view")),
):
"""待人工处理的重登失败账号,按失败原因分组。
自动重登只处理「还能自动救」的失败;极验滑块、凭证问题、以及重试次数用尽的
必须交给人 —— 这个接口就是那份清单(规划 §7.4.6)。
"""
from ..services.automation.jobs.ck_relogin import MANUAL_KINDS
# 转人工 = 分型属于需人工,或者次数用尽(next_run_at 已清空且失败过)。
manual_filter = or_(
LoginTask.relogin_failure_kind.in_(tuple(MANUAL_KINDS)),
and_(
LoginTask.relogin_last_error != "",
LoginTask.relogin_next_at.is_(None),
LoginTask.relogin_attempts > 0,
),
)
rows = (
db.query(LoginTask, Account.username)
.join(Account, LoginTask.account_id == Account.id)
.filter(LoginTask.ck_check_status == "invalid")
.filter(LoginTask.relogin_failure_kind != "")
.filter(manual_filter)
.order_by(LoginTask.attempt_at.desc(), LoginTask.id.desc())
.all()
)
grouped: dict[str, dict] = {}
for task, username in rows:
kind = task.relogin_failure_kind or ""
# 可重试的类型却已经没有下次排期 = 次数用尽,单独成组。
# 否则它会混进「验证码失败」组里,客服看不出「这个已经不会再自动试了」。
if kind not in MANUAL_KINDS:
kind = ""
group = grouped.setdefault(
kind,
{
"kind": kind,
"label": _manual_kind_label(kind),
"count": 0,
"samples": [],
},
)
group["count"] += 1
if len(group["samples"]) < limit:
group["samples"].append(
{
"task_id": task.id,
"account_username": username,
"relogin_attempts": int(task.relogin_attempts or 0),
"last_error": task.relogin_last_error or task.attempt_message or "",
"attempt_at": task.attempt_at.isoformat()
if task.attempt_at
else None,
}
)
groups = sorted(grouped.values(), key=lambda item: -item["count"])
return {"total": len(rows), "groups": groups}
@router.get("/runs")
def list_runs(
job_key: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
db: Session = Depends(get_db),
current: User = Depends(require_permission("automation:view")),
):
"""运行历史,最近的在前。"""
query = db.query(AutomationRun)
if job_key:
query = query.filter(AutomationRun.job_key == job_key)
runs = query.order_by(AutomationRun.id.desc()).limit(limit).all()
return {"items": [_run_out(run) for run in runs]}
def _tick_delta():
from datetime import timedelta
from ..services.automation.scheduler import TICK_SECONDS
return timedelta(seconds=TICK_SECONDS)