后台原本靠人工逐批点「CK 检测」发现失效账号,1.2 万个账号根本盯不过来。
P1a 把「哪些账号失效了」自动化:自动挑久未检测的账号、调现成检测接口、
把结果写回 ck_check_*。**本作业不替换任何 CK**,重登仍由人工点,符合规划里
「先做只读/只提示,确认无误再做自动动作」的渐进上线原则。
实现:
- 迁移 20261002_0037:automation_jobs / automation_runs 两张表,
login_tasks 加 relogin_attempts / relogin_next_at / relogin_last_error
(为 P1b 备好),accounts 加 auto_managed(特殊账号不参与自动化);
- services/automation/scheduler.py:进程内调度线程,60s tick,状态全部落库
(重启不丢调度进度、不重复跑轮);AUTOMATION_ENABLED 默认关闭,作业开关
也默认关闭;ensure_jobs 只补缺失行、不覆盖运维改过的配置;
作业异常只记 failed,不拖垮调度线程;单容器 --workers 1,无需分布式锁;
- services/automation/jobs/ck_health.py:选行口径与人工检测一致
(cookie_holding_filter:持有 CK 且无在途尝试),叠加久未检测、
auto_managed、batch_size,按 ck_checked_at 升序(最久没查的优先);
只写 ck_check_status / ck_check_result / ck_checked_at;
- routers/automation.py:GET /jobs、PATCH /jobs/{key}、POST /jobs/{key}/run、
GET /runs;新增权限点 automation:view(运营可看)与 automation:manage
(仅超管),改配置与手动触发都写审计;
- 顺带补 POST /api/login/batch 的审计(此前批次是谁发起、多少账号无据可查);
- 前端 /automation 页:作业卡片(开关/周期/单轮条数/并发)、运行历史、
「立即跑一次」;总开关未打开、调度线程未运行时给明确提示。
验证:
- 22 条新测试覆盖调度器行为(只跑到点的、异常不抛出、下轮时间、配置不被重置)、
选行条件(在途尝试/auto_managed/间隔/batch_size)、接口与权限,其中一条
专门断言巡检后 cookie/status/message/attempt_status 一字未动;
- 全量 257 条通过;前端 tsc 与构建通过;
- 端到端用 3 个真实账号跑真实斗鱼接口:巡检结果与直接调 check_douyu_cookie
逐条一致(3/3 valid),CK 与尝试状态全程未变。
顺带修掉一处会炸生产的坑:DB 时间列读出来是 naive UTC,直接与
datetime.now(UTC) 比较会 TypeError,现统一走 _as_utc() 补时区。
192 lines
6.1 KiB
Python
192 lines
6.1 KiB
Python
"""自动化作业接口。
|
|
|
|
设计上「先能看、再能开」:查看需要 ``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.orm import Session
|
|
|
|
from ..database import get_db
|
|
from ..deps import require_permission
|
|
from ..models import AutomationJob, AutomationRun, 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
|
|
|
|
|
|
@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)
|